From 87f23dcc3d4bc4a3bbbc3a42f5ece3b49e8db547 Mon Sep 17 00:00:00 2001 From: th555 Date: Sat, 20 Dec 2025 16:18:37 +0100 Subject: [PATCH 01/18] oonpia basics --- locales/en/apgames.json | 31 ++ playground/playground.html | 2 +- src/games/index.ts | 8 +- src/games/oonpia.ts | 724 +++++++++++++++++++++++++++++++++++++ 4 files changed, 762 insertions(+), 3 deletions(-) create mode 100644 src/games/oonpia.ts diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 0beb4d73..f36bcefc 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -131,6 +131,7 @@ "omny": "Generalized connection game where players try to split star cells into different regions so that no single region contains a majority of star cells.", "onager": "In Onager each player tries to reach the opponent's back rank. Onager is named after a Roman siege engine that is a type of catapult, as the way the pieces move resembles how projectiles are hurled forward with this device.", "onyx": "A connection game on a modified snub-square board with a capture rule.", + "oonpia": "A hexagonal go-like where pieces come in two types. Only pieces of opposite type are connected, and a 4-arc of pieces of the same type (regardless of colour) is forbidden.", "orb": "Generatorb is 2-player game played on a standard chess board. Players start in opposite corners and attempt to reach their opponent's generator core or occupy the majority of cells on the front line. During play, you can stack up to three checkers in a space. Stacks of different heights behave differently, leading to engaging strategic options.", "ordo": "Ordo is a \"get to your opponent's home row\" game in which you must always keep your pieces connected. Pieces can move singly and also as a group in certain situations. You can also win by breaking up your opponent's group in such a way that they can't reconnect it.", "oust": "Oust is the classic \"exnihilation\" game where the game starts with an empty board and the goal is to eliminate all of your opponent's pieces. On your turn, you may make multiple capturing placements if available, but you must end it with a non-capturing placement. You must pass if you are not able to make any placements.", @@ -1662,6 +1663,32 @@ "description": "Both players start with two pieces on each opponent's edge." } }, + "oonpia": { + "size-5": { + "name": "Size 5 board" + }, + "#board": { + "name": "Size 6 board" + }, + "size-7": { + "name": "Size 7 board" + }, + "size-8": { + "name": "Size 8 board" + }, + "size-9": { + "name": "Size 9 board" + }, + "size-10": { + "name": "Size 10 board" + }, + "size-11": { + "name": "Size 11 board" + }, + "size-12": { + "name": "Size 12 board" + } + }, "orb": { "noglobes": { "description": "Neither player starts with any globes.", @@ -4499,6 +4526,10 @@ "SINGLE_CAPTURE": "This move captures one pair, it has to be suffixed with 'x'. Try '{{move}}'.", "DOUBLE_CAPTURE": "This move captures two pairs, it has to be suffixed with 'xx'. Try '{{move}}'." }, + "oonpia": { + "DESTINATION": "Select a cell to place the piece.", + "INITIAL_INSTRUCTIONS": "Place 1 piece of either type on the board." + }, "orb": { "INITIAL_INSTRUCTIONS": "Select one of your own pieces to move.", "SEED_PARTIAL": "Select a destination.", diff --git a/playground/playground.html b/playground/playground.html index 6e0e3b7c..ae2acf8c 100644 --- a/playground/playground.html +++ b/playground/playground.html @@ -7,7 +7,7 @@ AP Game Tester - + diff --git a/src/games/index.ts b/src/games/index.ts index 773aede0..e90f852a 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -217,6 +217,7 @@ import { SunspotGame, ISunspotState } from "./sunspot"; import { StawvsGame, IStawvsState } from "./stawvs"; import { LascaGame, ILascaState } from "./lasca"; import { EmergoGame, IEmergoState } from "./emergo"; +import { OonpiaGame, IOonpiaState } from "./oonpia"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -436,6 +437,7 @@ export { StawvsGame, IStawvsState, LascaGame, ILascaState, EmergoGame, IEmergoState, + OonpiaGame, IOonpiaState, }; const games = new Map(); // Manually add each game to the following array @@ -543,7 +545,7 @@ const games = new Map { if (games.has(g.gameinfo.uid)) { @@ -988,6 +990,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new LascaGame(...args); case "emergo": return new EmergoGame(...args); + case "oonpia": + return new OonpiaGame(...args); } return; } diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts new file mode 100644 index 00000000..73f94122 --- /dev/null +++ b/src/games/oonpia.ts @@ -0,0 +1,724 @@ +/* eslint-disable no-console */ +import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep, AreaKey, BoardBasic } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { reviver, UserFacingError } from "../common"; +import i18next from "i18next"; +import { HexTriGraph } from "../common/graphs"; + +export type playerid = 1|2; +export type tileid = 1|2; +const tileNames = ["plain", "dotted"]; // For tileid 1 and 2 + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; + scores: number[]; +}; + +export interface IOonpiaState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class OonpiaGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Oonpia", + uid: "oonpia", + playercounts: [2], + version: "20251216", + dateAdded: "2025-12-16", + // i18next.t("apgames:descriptions.oonpia") + description: "apgames:descriptions.oonpia", + // i18next.t("apgames:notes.oonpia") + notes: "apgames:notes.oonpia", + urls: ["https://boardgamegeek.com/thread/3251219/oonpia-new-4-colour-hexagonal-go-like"], + people: [ + { + type: "designer", + name: "Hoembla", + urls: ["https://boardgamegeek.com/boardgamedesigner/148212/hoembla"], + apid: "36926ace-08c0-417d-89ec-15346119abf2", + }, + { + type: "coder", + name: "hoembla", + urls: [], + apid: "36926ace-08c0-417d-89ec-15346119abf2", + }, + ], + categories: ["mechanic>place", "mechanic>capture", "mechanic>enclose", "board>shape>hex", "board>connect>hex", "components>simple>2per"], + variants: [ + { uid: "size-5", group: "board" }, + { uid: "#board", }, + { uid: "size-7", group: "board" }, + { uid: "size-8", group: "board" }, + { uid: "size-9", group: "board" }, + { uid: "size-10", group: "board" }, + { uid: "size-11", group: "board" }, + { uid: "size-12", group: "board" } + ] + }; + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Map; + public graph: HexTriGraph = new HexTriGraph(7, 13); + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + public scores: number[] = [0, 0]; + private boardSize = 0; + + constructor(state?: IOonpiaState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined) { + this.variants = [...variants]; + } + const fresh: IMoveState = { + _version: OonpiaGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board: new Map(), + scores: [0, 0], + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as IOonpiaState; + } + if (state.game !== OonpiaGame.gameinfo.uid) { + throw new Error(`The Oonpia engine cannot process a game of '${state.game}'.`); + } + this.gameover = state.gameover; + this.winner = [...state.winner]; + this.variants = state.variants; + this.stack = [...state.stack]; + } + this.load(); + } + + public load(idx = -1): OonpiaGame { + 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 = new Map(state.board); + this.lastmove = state.lastmove; + this.results = [...state._results]; + this.scores = [...state.scores]; + this.boardSize = this.getBoardSize(); + this.buildGraph(); + 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 6; + } + + private getGraph(): HexTriGraph { + return new HexTriGraph(this.boardSize, this.boardSize * 2 - 1); + } + + private buildGraph(): OonpiaGame { + this.graph = this.getGraph(); + return this; + } + + public otherPlayer(): playerid { + return this.currplayer === 1 ? 2 : 1; + } + + public moves(player?: playerid): string[] { + if (this.gameover) { return []; } + if (player === undefined) { + player = this.currplayer; + } + + const moves: string[] = []; + const empties = (this.graph.listCells() as string[]).filter(c => ! this.board.has(c)).sort(); + // Get singles + for (const cell of empties) { + moves.push("1" + cell); + moves.push("2" + cell); + } + return moves; + } + + public randomMove(): string { + const moves = this.moves(); + return moves[Math.floor(Math.random() * moves.length)]; + } + + private splitTileCell(move: string): [tileid, string] { + // Split the move into tile and cell. + const tile = parseInt(move[0], 10); + const cell = move.slice(1); + if (tile !== 1 && tile !== 2) { + throw new Error(`Invalid tile: ${tile}`); + } + return [tile as tileid, cell]; + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + let newmove = ""; + const cell = this.graph.coords2algebraic(col, row); + if ((piece === "1" || piece === "2") && row === -1) { + newmove = piece; + } else if (move === "") { + newmove = `1${cell}`; + } else if (move === "1" || move === "2") { + if (row === -1) { + newmove = move; + } else { + newmove = `${move}${cell}`; + } + } else { + const moves = move.split(","); + if (moves.length === 1) { + const [tile, oldCell] = this.splitTileCell(moves[0]); + if (oldCell === cell) { + // Swap tile. + newmove = `${tile % 2 + 1}${cell}`; + } else if (tile === 1) { + newmove = `${moves[0]},2${cell}`; + } else { + newmove = `1${cell},${moves[0]}`; + } + } + } + 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 { + // the stone type (1 or 2) comes before the coordinate, e.g. 1c3 or 2c3 + if (m.length === 0) { + return { + valid: true, + complete: -1, + canrender: true, + message: i18next.t("apgames:validation.oonpia.INITIAL_INSTRUCTIONS"), + } + } else if (m.length === 1) { + if (m === '1' || m === '2') { + return { + valid: true, + complete: -1, + canrender: true, + message: i18next.t("apgames:validation.oonpia.DESTINATION") + } + } else { + return { + valid: false, + message: i18next.t("apgames:validation._general.INVALID_MOVE", { move: m }) + } + } + } else { + const coord = m.slice(1); + try { + this.graph.algebraic2coords(coord); + } catch { + return { + valid: false, + message: i18next.t("apgames:validation._general.INVALIDCELL", { move: m }) + } + } + if (this.board.has(coord)) { + return { + valid: false, + message: i18next.t("apgames:validation._general.OCCUPIED", {where: coord}) + } + } + } + + return { + valid: true, + complete: 1, + message: i18next.t("apgames:validation._general.VALID_MOVE") + } + } + + public move(m: string, { partial = false, trusted = false } = {}): OonpiaGame { + 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) + } + if (!partial && !this.moves().includes(m)) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) + } + } + + if (m.length === 0 || m === "1" || m === "2") { return this; } + + this.results = []; + const [tile, cell] = this.splitTileCell(m); + this.board.set(cell, [this.currplayer, tile]); + this.results.push({type: "place", where: cell, what: tile === 1 ? tileNames[0] : tileNames[1]}); + + if (partial) { return this; } + + // First capture other player's groups, then your own (if any) + + for (const group of this.deadGroups(this.otherPlayer())) { + for (const cell of group) { + this.board.delete(cell); + } + this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); + this.scores[this.currplayer - 1] += group.size; + } + + for (const group of this.deadGroups(this.currplayer)) { + for (const cell of group) { + this.board.delete(cell); + } + this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); + this.scores[this.currplayer - 1] += group.size; + } + + + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + this.checkEOG(); + this.saveState(); + return this; + } + + // private piecesByTile(player: playerid, tile: tileid): string[] { + // // Get all pieces owned by `player` and is `tile`. + // return [...this.board.entries()].filter(e => (e[1][0] === player) && (e[1][1] === tile)).map(e => e[0]); + // } + + private pieces(player: playerid): string[] { + // Get all pieces owned by `player` + return [...this.board.entries()].filter(e => (e[1][0] === player)).map(e => e[0]); + } + + private getGroups(player: playerid): Set[] { + // In oonpia only alternating types are connected, so dotted pieces only connect with undotted, and vice versa. + + // Get groups of cells that are connected to `cell` and owned by `player`. + const groups: Set[] = []; + const pieces = this.pieces(player); + // /* eslint-disable no-console */ console.log(pieces); + const seen: Set = new Set(); + for (const piece of pieces) { + if (seen.has(piece)) { + continue; + } + const group: Set = new Set(); + const todo: string[] = [piece] + while (todo.length > 0) { + const cell = todo.pop()!; + if (seen.has(cell)) { + continue; + } + group.add(cell); + seen.add(cell); + const neighbours = this.graph.neighbours(cell); + const myTile = this.board.get(cell)![1]; + for (const n of neighbours) { + if (pieces.includes(n) && this.board.get(n)![1] != myTile) { + todo.push(n); + } + } + } + groups.push(group); + } + return groups; + } + + private blockedCells(): {1: Set, 2: Set} { + // Return all cells blocked for playing plain resp. dotted stones (by the placement restriction + // that no 4-arc of same-type stones may occur) + + // - Iterate over all cells + // - For each cell look at the neighbours in clockwise direction + // - For each of these neighbours check whether it forms an arc together with the previous + // and/or subsequent neighbours + + const cells = this.graph.listCells() as string[]; + const blockedPlain: Set = new Set(); + const blockedDotted: Set = new Set(); + for (const cell of cells) { + const neighbours = this.graph.neighbours(cell); + if (neighbours.length < 4 || neighbours.filter(n => this.board.has(n)).length < 3) { + continue; + } + for (const type of [1, 2] as tileid[]){ + for (const [baseI, baseDir] of HexTriGraph.directions.entries()) { + const nbCoords = this.graph.move(...this.graph.algebraic2coords(cell), baseDir); + if (nbCoords === undefined) { continue; } + const nb = this.graph.coords2algebraic(...nbCoords); + if (this.board.has(nb)) { continue; } + // Checking for 4-arcs in the circular (clockwise) neighbours around this cell at all offsets + let arc = false; + for (let offsetI = 3; offsetI <= 6; offsetI++) { // going from 3 to 6 instead of -3 to 0, because modulo of negative numbers in js is bugged + let arcAtOffset = true; + for (let i = 0; i < 4; i++) { + const iterDir = HexTriGraph.directions[(baseI + offsetI + i) % 6]; + if (iterDir === baseDir) { continue; } + const offsetNbCoords = this.graph.move(...this.graph.algebraic2coords(cell), iterDir); + if (offsetNbCoords === undefined) { + arcAtOffset = false; + break; + } else { + const iCell = this.graph.coords2algebraic(...offsetNbCoords); + if (!this.board.has(iCell) || this.board.get(iCell)![1] !== type) { + arcAtOffset = false; + break; + } + } + } + if (arcAtOffset) { + arc = true; + break; + } + } + if (arc) { + (type === 1 ? blockedPlain : blockedDotted).add(nb); + } + } + } + } + return {1: blockedPlain, 2: blockedDotted}; + } + + private deadGroups(player: playerid): Set[] { + // Get all groups owned by `player` that are captured. + const captured: Set[] = []; + const groups = this.getGroups(player); + const blocked = this.blockedCells(); + loop: + for (const group of groups) { + for (const cell of group) { + for (const n of this.graph.neighbours(cell)) { + if (!this.board.has(n)) { + const myTile = this.board.get(cell)![1]; + const needTile = myTile === 1 ? 2 : 1; + if (!blocked[needTile].has(n)) { + continue loop; + } + } + } + } + captured.push(group); + } + return captured; + } + + protected checkEOG(): OonpiaGame { + // Two passes + + // const prevPlayer = this.currplayer % 2 + 1 as playerid; + // if (this.scores[prevPlayer - 1] >= this.threshold) { + // this.gameover = true; + // this.winner = [prevPlayer]; + // } + + // if (this.gameover) { + // this.results.push( + // {type: "eog"}, + // {type: "winners", players: [...this.winner]} + // ); + // } + + return this; + } + + public state(): IOonpiaState { + return { + game: OonpiaGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + public moveState(): IMoveState { + return { + _version: OonpiaGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + scores: [...this.scores], + }; + } + + public render(): APRenderRep { + // Build piece string + const pstr: string[][] = []; + const cells = this.graph.listCells(true); + for (const row of cells) { + const pieces: string[] = []; + for (const cell of row) { + if (this.board.has(cell)) { + const [player, tile] = this.board.get(cell)!; + if (player === 1) { + if (tile === 1) { + pieces.push("A"); + } else { + pieces.push("B"); + } + } else { + if (tile === 1) { + pieces.push("C"); + } else { + pieces.push("D"); + } + } + } else { + pieces.push("-"); + } + } + pstr.push(pieces); + } + + const s = this.boardSize - 1; + const boardcol = "#e0bb6c"; // colours from besogo viewer together with #252525, #eeeeee and #0165fc + const boardEdgeW = 55; + + // Build rep + const rep: APRenderRep = { + board: { + style: "hex-of-tri", + minWidth: this.boardSize, + maxWidth: this.boardSize * 2 - 1, + strokeWeight: 0.5, + markers: [ + { + type: "shading", + belowGrid: true, + points: [ + { row: 0, col: 0 }, + { row: 0, col: s }, + { row: s, col: s*2 }, + { row: s*2, col: s }, + { row: s*2, col: 0 }, + { row: s, col: 0 }, + ], + colour: boardcol, + opacity: 1, + }, + { + type: "line", + belowGrid: true, + points: [ + { row: 0, col: 0 }, + { row: 0, col: s} + ], + colour: boardcol, + width: boardEdgeW, + }, + { + type: "line", + belowGrid: true, + points: [ + { row: 0, col: s }, + { row: s, col: s*2 }, + ], + colour: boardcol, + width: boardEdgeW, + }, + { + type: "line", + belowGrid: true, + points: [ + { row: s, col: s*2 }, + { row: s*2, col: s }, + ], + colour: boardcol, + width: boardEdgeW, + }, + { + type: "line", + belowGrid: true, + points: [ + { row: s*2, col: s }, + { row: s*2, col: 0 }, + ], + colour: boardcol, + width: boardEdgeW, + }, + { + type: "line", + belowGrid: true, + points: [ + { row: s*2, col: 0 }, + { row: s, col: 0 }, + ], + colour: boardcol, + width: boardEdgeW, + }, + { + type: "line", + belowGrid: true, + points: [ + { row: s, col: 0 }, + { row: 0, col: 0 }, + ], + colour: boardcol, + width: boardEdgeW, + } + ] + }, + legend: { + A: {name: "piece-borderless", colour: 1, scale: 1.1}, + B: [ + {name: "piece-borderless", colour: 1, scale: 1.1}, + {name: "piece-borderless", colour: { + func: "bestContrast", + bg: 1, + fg: ["#000000", "#ffffff"], + }, scale: 0.363, opacity: 0.5} + ], + C: {name: "piece-borderless", colour: 2, scale: 1.1}, + D: [ + {name: "piece-borderless", colour: 2, scale: 1.1}, + {name: "piece-borderless", colour: { + func: "bestContrast", + bg: 2, + fg: ["#000000", "#ffffff"], + }, scale: 0.363, opacity: 0.5} + ], + }, + pieces: pstr.map(p => p.join("")).join("\n"), + }; + + // Add key so the user can click to select the color to place + const key: AreaKey = { + type: "key", + position: "left", + height: 0.7, + list: [ + { piece: this.currplayer === 1 ? "A" : "C", name: "", value: "1" }, + { piece: this.currplayer === 1 ? "B" : "D", name: "", value: "2" }, + ], + clickable: true, + }; + rep.areas = [key]; + const {1: blockedPlain, 2: blockedDotted} = this.blockedCells(); + for (const cell of blockedPlain) { + const [x, y] = this.graph.algebraic2coords(cell); + if ("markers" in (rep.board! as BoardBasic)) { // make the compiler happy + ((rep.board! as BoardBasic).markers!).push({ + type: "dots", + points: [{row: y, col: x}], + colour: "#000", + opacity: 0.3, + size: 0.3 + }) + } + } + for (const cell of blockedDotted) { + const [x, y] = this.graph.algebraic2coords(cell); + if ("markers" in (rep.board! as BoardBasic)) { // make the compiler happy + ((rep.board! as BoardBasic).markers!).push({ + type: "dots", + points: [{row: y, col: x}], + colour: "#000", + opacity: 0.3, + size: 0.9 + }) + } + } + console.log("yep"); + + // Add annotations + if (this.stack[this.stack.length - 1]._results.length > 0) { + rep.annotations = []; + for (const move of this.stack[this.stack.length - 1]._results) { + if (move.type === "place") { + const [x, y] = this.graph.algebraic2coords(move.where!); + rep.annotations.push({type: "enter", targets: [{row: y, col: x}]}); + } else if (move.type === "capture") { + const targets: {row: number, col: number}[] = []; + for (const m of move.where!.split(",")) { + const [x, y] = this.graph.algebraic2coords(m); + targets.push({row: y, col: x}); + } + rep.annotations.push({type: "exit", targets: targets as [{row: number; col: number;}, ...{row: number; col: number;}[]]}); + } + } + } + return rep; + } + + public status(): string { + let status = super.status(); + + if (this.variants !== undefined) { + status += "**Variants**: " + this.variants.join(", ") + "\n\n"; + } + + status += "**Score**:\n\n"; + for (let n = 1; n <= this.numplayers; n++) { + status += `Player ${n}: ${this.scores[n - 1]}\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.oonpia", {player, where: r.where, what: r.what})); + resolved = true; + break; + case "capture": + node.push(i18next.t("apresults:CAPTURE.oonpia", {player, count: r.count, what: r.what})); + resolved = true; + break; + } + return resolved; + } + + public clone(): OonpiaGame { + return new OonpiaGame(this.serialize()); + } +} From 2e3e547a15777f5a798710b0d946afc664e447bc Mon Sep 17 00:00:00 2001 From: th555 Date: Sat, 20 Dec 2025 16:44:07 +0100 Subject: [PATCH 02/18] oonpia basics --- src/games/oonpia.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 73f94122..85b025b4 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -197,17 +197,12 @@ export class OonpiaGame extends GameBase { newmove = `${move}${cell}`; } } else { - const moves = move.split(","); - if (moves.length === 1) { - const [tile, oldCell] = this.splitTileCell(moves[0]); - if (oldCell === cell) { - // Swap tile. - newmove = `${tile % 2 + 1}${cell}`; - } else if (tile === 1) { - newmove = `${moves[0]},2${cell}`; - } else { - newmove = `1${cell},${moves[0]}`; - } + const [tile, oldCell] = this.splitTileCell(move); + if (oldCell === cell) { + // Swap tile. + newmove = `${tile % 2 + 1}${cell}`; + } else { + return this.handleClick("", row, col, piece); } } const result = this.validateMove(newmove) as IClickResult; From a59dba55515cd01381164eec73898ebd4b18f181 Mon Sep 17 00:00:00 2001 From: th555 Date: Sat, 20 Dec 2025 21:25:35 +0100 Subject: [PATCH 03/18] automatically pick valid or most likely stone type on click --- locales/en/apgames.json | 3 ++- src/games/oonpia.ts | 41 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index f36bcefc..c5b1af3e 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -4528,7 +4528,8 @@ }, "oonpia": { "DESTINATION": "Select a cell to place the piece.", - "INITIAL_INSTRUCTIONS": "Place 1 piece of either type on the board." + "INITIAL_INSTRUCTIONS": "Place 1 piece of either type on the board.", + "ARC": "You may not form a size 4 arc of stones of one type, regardless of colour." }, "orb": { "INITIAL_INSTRUCTIONS": "Select one of your own pieces to move.", diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 85b025b4..97e1c4f4 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -189,7 +189,31 @@ export class OonpiaGame extends GameBase { if ((piece === "1" || piece === "2") && row === -1) { newmove = piece; } else if (move === "") { - newmove = `1${cell}`; + const blocked = this.blockedCells(); + if (blocked[1].has(cell) && blocked[2].has(cell)) { + move = `1${cell}`; // This move is invalid, but we'll let validateMove give the correct + // error message + } else if (blocked[1].has(cell)){ + newmove = `2${cell}`; + } else if (blocked[2].has(cell)){ + newmove = `1${cell}`; + } else { + // Both tiles are valid. If there are friendly neighbouring stones and they are + // of one type only, automatically place the other type. + const friendlyTiles = new Set( + this.graph.neighbours(cell) + .filter(c => this.board.has(c)) + .map(c => this.board.get(c)) + .filter(piece => piece![0] === this.currplayer) + .map(piece => piece![1]) + ); + if (friendlyTiles.size === 1) { + const otherTile = [...friendlyTiles][0] === 1 ? 2 : 1; + newmove = `${otherTile}${cell}`; + } else { + newmove = `1${cell}`; + } + } } else if (move === "1" || move === "2") { if (row === -1) { newmove = move; @@ -245,19 +269,26 @@ export class OonpiaGame extends GameBase { } } } else { - const coord = m.slice(1); + const [tile, cell] = this.splitTileCell(m); try { - this.graph.algebraic2coords(coord); + this.graph.algebraic2coords(cell); } catch { return { valid: false, message: i18next.t("apgames:validation._general.INVALIDCELL", { move: m }) } } - if (this.board.has(coord)) { + if (this.board.has(cell)) { + return { + valid: false, + message: i18next.t("apgames:validation._general.OCCUPIED", {where: cell}) + } + } + const blockedCells = this.blockedCells(); + if (blockedCells[tile].has(cell)) { return { valid: false, - message: i18next.t("apgames:validation._general.OCCUPIED", {where: coord}) + message: i18next.t("apgames:validation.oonpia.ARC", {where: cell}) } } } From 1ec439684c80e9fcbadd328977cd1a36699482e3 Mon Sep 17 00:00:00 2001 From: th555 Date: Sun, 21 Dec 2025 22:46:45 +0100 Subject: [PATCH 04/18] better move string validation --- locales/en/apgames.json | 4 +- src/games/oonpia.ts | 248 +++++++++++++++++++++++++++++----------- 2 files changed, 183 insertions(+), 69 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index c5b1af3e..8e37ce8e 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -4529,7 +4529,9 @@ "oonpia": { "DESTINATION": "Select a cell to place the piece.", "INITIAL_INSTRUCTIONS": "Place 1 piece of either type on the board.", - "ARC": "You may not form a size 4 arc of stones of one type, regardless of colour." + "ARC": "You may not form a size 4 arc of stones of one type, regardless of colour.", + "INVALID_CAPTURE": "A blue stone must always capture some enemy or friendly stones.", + "INVALID_PLACE": "Placing a stone of your own colour may not capture any stones." }, "orb": { "INITIAL_INSTRUCTIONS": "Select one of your own pieces to move.", diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 97e1c4f4..5ae6e7ea 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -7,15 +7,24 @@ import { reviver, UserFacingError } from "../common"; import i18next from "i18next"; import { HexTriGraph } from "../common/graphs"; -export type playerid = 1|2; +export type playerid = 1|2|3; // 3 is neutral export type tileid = 1|2; const tileNames = ["plain", "dotted"]; // For tileid 1 and 2 +// Moves are always represented from the perspective of the current player (so we don't need +// to store the stone colour). All fields are optional, so we can use undefined to represent partial +// or partially parsed moves. +type Move = { + tile?: tileid; + cell?: string; + iscapture?: boolean; +} + export interface IMoveState extends IIndividualState { currplayer: playerid; board: Map; lastmove?: string; - scores: number[]; + prison: [number,number]; }; export interface IOonpiaState extends IAPGameState { @@ -64,6 +73,7 @@ export class OonpiaGame extends GameBase { public numplayers = 2; public currplayer: playerid = 1; + public neutral: playerid = 3; public board!: Map; public graph: HexTriGraph = new HexTriGraph(7, 13); public gameover = false; @@ -71,7 +81,7 @@ export class OonpiaGame extends GameBase { public variants: string[] = []; public stack!: Array; public results: Array = []; - public scores: number[] = [0, 0]; + public prison: [number, number] = [0, 0]; private boardSize = 0; constructor(state?: IOonpiaState | string, variants?: string[]) { @@ -86,7 +96,7 @@ export class OonpiaGame extends GameBase { _timestamp: new Date(), currplayer: 1, board: new Map(), - scores: [0, 0], + prison: [0, 0], }; this.stack = [fresh]; } else { @@ -117,7 +127,7 @@ export class OonpiaGame extends GameBase { this.board = new Map(state.board); this.lastmove = state.lastmove; this.results = [...state._results]; - this.scores = [...state.scores]; + this.prison = [...state.prison]; this.boardSize = this.getBoardSize(); this.buildGraph(); return this; @@ -159,10 +169,14 @@ export class OonpiaGame extends GameBase { const moves: string[] = []; const empties = (this.graph.listCells() as string[]).filter(c => ! this.board.has(c)).sort(); - // Get singles + const blocked = this.blockedCells(); for (const cell of empties) { - moves.push("1" + cell); - moves.push("2" + cell); + if (!blocked[1].has(cell)) { + moves.push("1" + cell); + } + if (!blocked[2].has(cell)) { + moves.push("2" + cell); + } } return moves; } @@ -245,59 +259,117 @@ export class OonpiaGame extends GameBase { } } - public validateMove(m: string): IValidationResult { + private parseMoveString(m: string, move: Move = {}): Move | undefined { // the stone type (1 or 2) comes before the coordinate, e.g. 1c3 or 2c3 - if (m.length === 0) { + // After that an 'X' if it's a capture using neutral stone, e.g. 1Xc3, 2Xc3 + // Return a Move object if it's valid, undefined if the string is invalid + // This checks that a cell exists on the board, but not if it's occupied etc... + if (m === "" || m === undefined) { + return move; + } + if (move.tile === undefined) { + const ts = m.slice(0, 1); + const rest = m.slice(1); + let tile: tileid; + if (ts === "1") { + tile = 1; + } else if (ts === "2") { + tile = 2; + } else { + return undefined; + } + return this.parseMoveString(rest, {tile: tile}); + } + if (move.iscapture === undefined) { + if (m.startsWith("X") { + const rest = m.slice(1); + move.iscapture = true; + return this.parseMoveString(rest, move); + } else { + move.iscapture = false; + return this.parseMoveString(m, move); + } + } + if (move.cell === undefined) { + try { + this.graph.algebraic2coords(m); + move.cell = m; + return move; + } catch { + return undefined + } + } + return undefined; + } + + public validateMove(m: string): IValidationResult { + const move = this.parseMoveString(m); + if (move === undefined) { + return { + valid: false, + message: i18next.t("apgames:validation._general.INVALID_MOVE", { move: m }) + } + } + if (move.tile === undefined) { return { valid: true, complete: -1, canrender: true, message: i18next.t("apgames:validation.oonpia.INITIAL_INSTRUCTIONS"), } - } else if (m.length === 1) { - if (m === '1' || m === '2') { + } + if (move.iscapture === undefined || move.cell === undefined) { + return { + valid: true, + complete: -1, + canrender: false, + message: i18next.t("apgames:validation.oonpia.DESTINATION") + } + } + + /* from here on out we know that the move string is valid and complete */ + + if (this.board.has(move.cell)) { + return { + valid: false, + message: i18next.t("apgames:validation._general.OCCUPIED", {where: move.cell}) + } + } + const blockedCells = this.blockedCells(); + if (blockedCells[move.tile].has(move.cell)) { + return { + valid: false, + message: i18next.t("apgames:validation.oonpia.ARC", {where: move.cell}) + } + } + + if (move.iscapture) { + if (this.isValidCapture(move.cell, move.tile) { return { valid: true, - complete: -1, - canrender: true, - message: i18next.t("apgames:validation.oonpia.DESTINATION") + complete: 1, + message: i18next.t("apgames:validation._general.VALID_MOVE") } } else { return { valid: false, - message: i18next.t("apgames:validation._general.INVALID_MOVE", { move: m }) + message: i18next.t("apgames:validation.oonpia.INVALID_CAPTURE") } } } else { - const [tile, cell] = this.splitTileCell(m); - try { - this.graph.algebraic2coords(cell); - } catch { - return { - valid: false, - message: i18next.t("apgames:validation._general.INVALIDCELL", { move: m }) - } - } - if (this.board.has(cell)) { + if (this.isValidPlace(move.cell, move.tile) { return { - valid: false, - message: i18next.t("apgames:validation._general.OCCUPIED", {where: cell}) + valid: true, + complete: 1, + message: i18next.t("apgames:validation._general.VALID_MOVE") } - } - const blockedCells = this.blockedCells(); - if (blockedCells[tile].has(cell)) { + } else { return { valid: false, - message: i18next.t("apgames:validation.oonpia.ARC", {where: cell}) + message: i18next.t("apgames:validation.oonpia.INVALID_PLACE") } } } - - return { - valid: true, - complete: 1, - message: i18next.t("apgames:validation._general.VALID_MOVE") - } } public move(m: string, { partial = false, trusted = false } = {}): OonpiaGame { @@ -322,27 +394,33 @@ export class OonpiaGame extends GameBase { this.board.set(cell, [this.currplayer, tile]); this.results.push({type: "place", where: cell, what: tile === 1 ? tileNames[0] : tileNames[1]}); - if (partial) { return this; } - + // First capture other player's groups, then your own (if any) - + let capd = false; for (const group of this.deadGroups(this.otherPlayer())) { + capd = true; for (const cell of group) { this.board.delete(cell); } this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); - this.scores[this.currplayer - 1] += group.size; + this.prison[this.currplayer - 1] += group.size; } for (const group of this.deadGroups(this.currplayer)) { + capd = true for (const cell of group) { this.board.delete(cell); } this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); - this.scores[this.currplayer - 1] += group.size; + this.prison[this.currplayer - 1] += group.size; } - + if (capd) { + this.board.set(cell, [this.neutral, tile]); + } + + if (partial) { return this; } + this.lastmove = m; this.currplayer = this.currplayer % 2 + 1 as playerid; this.checkEOG(); @@ -350,11 +428,6 @@ export class OonpiaGame extends GameBase { return this; } - // private piecesByTile(player: playerid, tile: tileid): string[] { - // // Get all pieces owned by `player` and is `tile`. - // return [...this.board.entries()].filter(e => (e[1][0] === player) && (e[1][1] === tile)).map(e => e[0]); - // } - private pieces(player: playerid): string[] { // Get all pieces owned by `player` return [...this.board.entries()].filter(e => (e[1][0] === player)).map(e => e[0]); @@ -394,7 +467,7 @@ export class OonpiaGame extends GameBase { return groups; } - private blockedCells(): {1: Set, 2: Set} { + private blockedCells(board = this.board): {1: Set, 2: Set} { // Return all cells blocked for playing plain resp. dotted stones (by the placement restriction // that no 4-arc of same-type stones may occur) @@ -403,12 +476,14 @@ export class OonpiaGame extends GameBase { // - For each of these neighbours check whether it forms an arc together with the previous // and/or subsequent neighbours + // TODO caching + const cells = this.graph.listCells() as string[]; const blockedPlain: Set = new Set(); const blockedDotted: Set = new Set(); for (const cell of cells) { const neighbours = this.graph.neighbours(cell); - if (neighbours.length < 4 || neighbours.filter(n => this.board.has(n)).length < 3) { + if (neighbours.length < 4 || neighbours.filter(n => board.has(n)).length < 3) { continue; } for (const type of [1, 2] as tileid[]){ @@ -416,7 +491,7 @@ export class OonpiaGame extends GameBase { const nbCoords = this.graph.move(...this.graph.algebraic2coords(cell), baseDir); if (nbCoords === undefined) { continue; } const nb = this.graph.coords2algebraic(...nbCoords); - if (this.board.has(nb)) { continue; } + if (board.has(nb)) { continue; } // Checking for 4-arcs in the circular (clockwise) neighbours around this cell at all offsets let arc = false; for (let offsetI = 3; offsetI <= 6; offsetI++) { // going from 3 to 6 instead of -3 to 0, because modulo of negative numbers in js is bugged @@ -430,7 +505,7 @@ export class OonpiaGame extends GameBase { break; } else { const iCell = this.graph.coords2algebraic(...offsetNbCoords); - if (!this.board.has(iCell) || this.board.get(iCell)![1] !== type) { + if (!board.has(iCell) || board.get(iCell)![1] !== type) { arcAtOffset = false; break; } @@ -450,17 +525,17 @@ export class OonpiaGame extends GameBase { return {1: blockedPlain, 2: blockedDotted}; } - private deadGroups(player: playerid): Set[] { + private deadGroups(player: playerid, board = this.board): Set[] { // Get all groups owned by `player` that are captured. const captured: Set[] = []; const groups = this.getGroups(player); - const blocked = this.blockedCells(); + const blocked = this.blockedCells(board); loop: for (const group of groups) { for (const cell of group) { for (const n of this.graph.neighbours(cell)) { - if (!this.board.has(n)) { - const myTile = this.board.get(cell)![1]; + if (!board.has(n)) { + const myTile = board.get(cell)![1]; const needTile = myTile === 1 ? 2 : 1; if (!blocked[needTile].has(n)) { continue loop; @@ -473,11 +548,36 @@ export class OonpiaGame extends GameBase { return captured; } + private isValidPlace(cell: string, tile: tileid): boolean { + // It's a valid placement of a player (non-blue) stone, i.e. no groups will be captured + const tmpboard = new Map(this.board); + tmpboard.set(cell, [this.currplayer, tile]); + return ( + this.deadGroups(this.otherPlayer(), tmpboard).length === 0 && + this.deadGroups(this.currplayer, tmpboard).length === 0 + ) + } + + private isValidCapture(cell: string, tile: tileid): boolean { + // It's a valid capture (i.e. blue stone placement), at least one stone (friendly or not) + // will be captured + const tmpboard = new Map(this.board); + tmpboard.set(cell, [this.neutral, tile]); + return ( + this.deadGroups(this.otherPlayer(), tmpboard).length > 0 || + this.deadGroups(this.currplayer, tmpboard).length > 0 + ) + } + protected checkEOG(): OonpiaGame { - // Two passes + // TODO + + // Two passes? or just resign? + // strictly speaking: """If you move to the prison the last enemy group on the board, you win. Otherwise, if you move to the prison the last friendly group on the board, you lose.""" + // check what asli does // const prevPlayer = this.currplayer % 2 + 1 as playerid; - // if (this.scores[prevPlayer - 1] >= this.threshold) { + // if (this.prison[prevPlayer - 1] >= this.threshold) { // this.gameover = true; // this.winner = [prevPlayer]; // } @@ -511,7 +611,7 @@ export class OonpiaGame extends GameBase { currplayer: this.currplayer, lastmove: this.lastmove, board: new Map(this.board), - scores: [...this.scores], + prison: [...this.prison], }; } @@ -530,12 +630,18 @@ export class OonpiaGame extends GameBase { } else { pieces.push("B"); } - } else { + } else if (player === 2) { if (tile === 1) { pieces.push("C"); } else { pieces.push("D"); } + } else { + if (tile === 1) { + pieces.push("E"); + } else { + pieces.push("F"); + } } } else { pieces.push("-"); @@ -651,6 +757,16 @@ export class OonpiaGame extends GameBase { fg: ["#000000", "#ffffff"], }, scale: 0.363, opacity: 0.5} ], + E: {name: "piece-borderless", colour: 3, scale: 1.1}, + F: [ + {name: "piece-borderless", colour: 3, scale: 1.1}, + {name: "piece-borderless", colour: { + func: "bestContrast", + bg: 3, + fg: ["#000000", "#ffffff"], + }, scale: 0.363, opacity: 0.5} + ], + }, pieces: pstr.map(p => p.join("")).join("\n"), }; @@ -675,7 +791,7 @@ export class OonpiaGame extends GameBase { type: "dots", points: [{row: y, col: x}], colour: "#000", - opacity: 0.3, + opacity: 0.2, size: 0.3 }) } @@ -687,7 +803,7 @@ export class OonpiaGame extends GameBase { type: "dots", points: [{row: y, col: x}], colour: "#000", - opacity: 0.3, + opacity: 0.2, size: 0.9 }) } @@ -720,11 +836,7 @@ export class OonpiaGame extends GameBase { if (this.variants !== undefined) { status += "**Variants**: " + this.variants.join(", ") + "\n\n"; } - - status += "**Score**:\n\n"; - for (let n = 1; n <= this.numplayers; n++) { - status += `Player ${n}: ${this.scores[n - 1]}\n\n`; - } + status += "**Prison**: " + this.prison.join(", ") + "\n\n"; return status; } From a4dde4276bc82e38aef4195ffa3108c480fa15ca Mon Sep 17 00:00:00 2001 From: th555 Date: Mon, 22 Dec 2025 16:01:49 +0100 Subject: [PATCH 05/18] click handling --- locales/en/apgames.json | 3 +- src/games/oonpia.ts | 260 +++++++++++++++++++++++++++++----------- 2 files changed, 195 insertions(+), 68 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 8e37ce8e..fefd76b1 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -4531,7 +4531,8 @@ "INITIAL_INSTRUCTIONS": "Place 1 piece of either type on the board.", "ARC": "You may not form a size 4 arc of stones of one type, regardless of colour.", "INVALID_CAPTURE": "A blue stone must always capture some enemy or friendly stones.", - "INVALID_PLACE": "Placing a stone of your own colour may not capture any stones." + "INVALID_PLACE": "Placing a stone of your own colour may not capture any stones.", + "INVALID_BOTH": "Neither a placement nor a capture is possible at {{where}}." }, "orb": { "INITIAL_INSTRUCTIONS": "Select one of your own pieces to move.", diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 5ae6e7ea..08757a4e 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -196,80 +196,194 @@ export class OonpiaGame extends GameBase { return [tile as tileid, cell]; } - public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { - try { - let newmove = ""; - const cell = this.graph.coords2algebraic(col, row); - if ((piece === "1" || piece === "2") && row === -1) { - newmove = piece; - } else if (move === "") { - const blocked = this.blockedCells(); - if (blocked[1].has(cell) && blocked[2].has(cell)) { - move = `1${cell}`; // This move is invalid, but we'll let validateMove give the correct - // error message - } else if (blocked[1].has(cell)){ - newmove = `2${cell}`; - } else if (blocked[2].has(cell)){ - newmove = `1${cell}`; - } else { - // Both tiles are valid. If there are friendly neighbouring stones and they are - // of one type only, automatically place the other type. - const friendlyTiles = new Set( - this.graph.neighbours(cell) - .filter(c => this.board.has(c)) - .map(c => this.board.get(c)) - .filter(piece => piece![0] === this.currplayer) - .map(piece => piece![1]) - ); - if (friendlyTiles.size === 1) { - const otherTile = [...friendlyTiles][0] === 1 ? 2 : 1; - newmove = `${otherTile}${cell}`; - } else { - newmove = `1${cell}`; - } - } - } else if (move === "1" || move === "2") { - if (row === -1) { - newmove = move; + private preferDotted(cell: string): boolean { + /* Check that all adjacent friendly stones are plain, so we want to default to placing + a dotted stone next to them */ + const friendlyTiles = new Set( + this.graph.neighbours(cell) + .filter(c => this.board.has(c)) + .map(c => this.board.get(c)) + .filter(piece => piece![0] === this.currplayer) + .map(piece => piece![1]) + ); + return friendlyTiles.size === 1 && [...friendlyTiles][0] === 1 + } + + private validateMoveAsClick(move: Move, oldms: string): IClickResult { + const newms = this.move2string(move); + const result = this.validateMove(newms) as IClickResult; + if (result.valid) { + result.move = newms; + } else { + result.move = oldms; + } + return result; + } + + public handleClick(ms: string, row: number, col: number, piece?: string): IClickResult { + let move = this.parseMoveString(ms); + if (move === undefined) { + return { + move: ms, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", ms) + }; + } + + if (row === -1) { + /* Always reset if player clicks the legend */ + if (piece) { + move = this.parseMoveString(piece); + if (move === undefined) { + return { + move: ms, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", ms) + }; } else { - newmove = `${move}${cell}`; + return this.validateMoveAsClick(move, ms); } - } else { - const [tile, oldCell] = this.splitTileCell(move); - if (oldCell === cell) { - // Swap tile. - newmove = `${tile % 2 + 1}${cell}`; - } else { - return this.handleClick("", row, col, piece); + } + } + + const cell = this.validateRowColAsCell(row, col); + + if (cell === undefined) { + return { + move: ms, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", ms) + }; + } + + if (move.cell === undefined && move.tile !== undefined) { + /* player has clicked the legend previously, so try to validate this specific + piece and capture choice */ + move.cell = cell; + return this.validateMoveAsClick(move, ms); + } + + if (move.cell !== undefined && move.cell !== cell) { + /* clicked again on other cell, reset */ + return this.handleClick("", row, col); + } + + // First we build a list of all possible tile and capture combinations at this cell, + // in the most likely default order. + // Then we take the first if there is no piece yet, or cycle through the list if a placed + // piece was clicked again. + + const place: tileid[] = []; + const cap: tileid[] = []; + + // First the normal placements: + // - If a cell is blocked for one type by the arc rule, only the other type is valid + // - Otherwise, if both types can be placed, prefer placing a connecting type + // - If there is no preferred connecting type start with type 1 and then type 2 + const blocked = this.blockedCells(); + + if (blocked[1].has(cell) && !blocked[2].has(cell)) { + if (this.isValidPlace(cell, 2)) { + place.push(2); + } + if (this.isValidCapture(cell, 2)) { + cap.push(2); + } + } else if (blocked[2].has(cell) && !blocked[1].has(cell)) { + if (this.isValidPlace(cell, 1)) { + place.push(1); + } + if (this.isValidCapture(cell, 1)) { + cap.push(1); + } + } else { + /* Both tiles are valid. If there are friendly neighbouring stones and they are + of one type only, default to placing the other type to form a connection. */ + for (let tile of (this.preferDotted(cell) ? [2, 1] : [1, 2]) as tileid[]) { + if (this.isValidPlace(cell, tile)) { + place.push(tile); } } - const result = this.validateMove(newmove) as IClickResult; - if (!result.valid) { - result.move = move; - } else { - result.move = newmove; + for (let tile of [1, 2] as tileid[]) { + if (this.isValidCapture(cell, tile)) { + cap.push(tile); + } } - return result; - } catch (e) { + } + + // Capturing placements: + // - If one includes self-capture and the other doesn't, prefer the latter + if (cap.length === 2 + && this.isSelfCapture(cell, cap[0]) + && !this.isSelfCapture(cell, cap[1]) + ) { + [cap[0], cap[1]] = [cap[1], cap[0]]; + } + + const possibleMoves: Move[] = []; + for (const tile of place) { + possibleMoves.push({tile: tile, iscapture: false, cell: cell}); + } + for (const tile of cap) { + possibleMoves.push({tile: tile, iscapture: true, cell: cell}); + } + + if (possibleMoves.length === 0) { return { - move, valid: false, - message: i18next.t("apgames:validation._general.GENERIC", {move, row, col, piece, emessage: (e as Error).message}) - }; + message: i18next.t("apgames:validation.oonpia.INVALID_BOTH", {where: move.cell}), + move: ms + } + } + + if (move.cell === undefined) { + /* place a piece for the first time */ + return this.validateMoveAsClick(possibleMoves[0], ms); + } else if (cell === move.cell) { + /* clicked again on placed piece, cycle through types */ + let i = 0; + for (; i < possibleMoves.length; i++) { + if (possibleMoves[i].tile === move.tile && possibleMoves[i].iscapture === move.iscapture) { + break; + } + } + const newi = (i + 1) % possibleMoves.length; + return this.validateMoveAsClick(possibleMoves[newi], ms); + } + + /* this shouldn't happen but the compiler doesn't know it */ + return this.validateMoveAsClick({tile: 1, iscapture: false, cell: cell}, ms); + } + + private validateCell(ms: string): string | undefined { + try { + this.graph.algebraic2coords(ms); + return ms; + } catch { + return undefined } } - private parseMoveString(m: string, move: Move = {}): Move | undefined { + private validateRowColAsCell(row: number, col: number): string | undefined { + try { + const cell = this.graph.coords2algebraic(col, row); + return this.validateCell(cell); + } catch { + return undefined + } + } + + private parseMoveString(ms: string, move: Move = {}): Move | undefined { // the stone type (1 or 2) comes before the coordinate, e.g. 1c3 or 2c3 // After that an 'X' if it's a capture using neutral stone, e.g. 1Xc3, 2Xc3 // Return a Move object if it's valid, undefined if the string is invalid // This checks that a cell exists on the board, but not if it's occupied etc... - if (m === "" || m === undefined) { + if (ms === "" || ms === undefined) { return move; } if (move.tile === undefined) { - const ts = m.slice(0, 1); - const rest = m.slice(1); + const ts = ms.slice(0, 1); + const rest = ms.slice(1); let tile: tileid; if (ts === "1") { tile = 1; @@ -281,8 +395,8 @@ export class OonpiaGame extends GameBase { return this.parseMoveString(rest, {tile: tile}); } if (move.iscapture === undefined) { - if (m.startsWith("X") { - const rest = m.slice(1); + if (ms.startsWith("X")) { + const rest = ms.slice(1); move.iscapture = true; return this.parseMoveString(rest, move); } else { @@ -291,17 +405,21 @@ export class OonpiaGame extends GameBase { } } if (move.cell === undefined) { - try { - this.graph.algebraic2coords(m); - move.cell = m; + const coords = this.validateCell(ms); + if (coords === undefined) { + return undefined; + } else { + move.cell = ms; return move; - } catch { - return undefined } } return undefined; } + private move2string(move: Move): string { + return `${move.tile || ''}${move.iscapture ? 'X' : ''}${move.cell || ''}` + } + public validateMove(m: string): IValidationResult { const move = this.parseMoveString(m); if (move === undefined) { @@ -339,12 +457,12 @@ export class OonpiaGame extends GameBase { if (blockedCells[move.tile].has(move.cell)) { return { valid: false, - message: i18next.t("apgames:validation.oonpia.ARC", {where: move.cell}) + message: i18next.t("apgames:validation.oonpia.ARC") } } if (move.iscapture) { - if (this.isValidCapture(move.cell, move.tile) { + if (this.isValidCapture(move.cell, move.tile)) { return { valid: true, complete: 1, @@ -357,7 +475,7 @@ export class OonpiaGame extends GameBase { } } } else { - if (this.isValidPlace(move.cell, move.tile) { + if (this.isValidPlace(move.cell, move.tile)) { return { valid: true, complete: 1, @@ -568,6 +686,12 @@ export class OonpiaGame extends GameBase { this.deadGroups(this.currplayer, tmpboard).length > 0 ) } + + private isSelfCapture(cell: string, tile: tileid): boolean { + const tmpboard = new Map(this.board); + tmpboard.set(cell, [this.neutral, tile]); + return this.deadGroups(this.currplayer, tmpboard).length > 0 + } protected checkEOG(): OonpiaGame { // TODO @@ -779,6 +903,8 @@ export class OonpiaGame extends GameBase { list: [ { piece: this.currplayer === 1 ? "A" : "C", name: "", value: "1" }, { piece: this.currplayer === 1 ? "B" : "D", name: "", value: "2" }, + { piece: "E", name: "", value: "1X" }, + { piece: "F", name: "", value: "2X" }, ], clickable: true, }; From 581b44c7debd42b5a3c40c67150a737a5a5f458c Mon Sep 17 00:00:00 2001 From: th555 Date: Mon, 22 Dec 2025 18:13:05 +0100 Subject: [PATCH 06/18] apply move from object --- src/games/oonpia.ts | 54 ++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 08757a4e..8c09f4db 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -490,56 +490,56 @@ export class OonpiaGame extends GameBase { } } - public move(m: string, { partial = false, trusted = false } = {}): OonpiaGame { + public move(ms: string, { partial = false, trusted = false } = {}): OonpiaGame { if (this.gameover) { throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); } if (!trusted) { - const result = this.validateMove(m); + const result = this.validateMove(ms); if (!result.valid) { throw new UserFacingError("VALIDATION_GENERAL", result.message) } if (!partial && !this.moves().includes(m)) { - throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: ms})) } } + + const move = this.parseMoveString(ms); + + if (move === undefined) { + throw new UserFacingError("VALIDATION_GENERAL", "Invalid movestring encountered."); + } - if (m.length === 0 || m === "1" || m === "2") { return this; } + if (move.cell === undefined) { return this; } // Partial move this.results = []; - const [tile, cell] = this.splitTileCell(m); - this.board.set(cell, [this.currplayer, tile]); - this.results.push({type: "place", where: cell, what: tile === 1 ? tileNames[0] : tileNames[1]}); + this.board.set(move.cell, [this.currplayer, move.tile]); + this.results.push({type: "place", where: move.cell, what: move.tile === 1 ? tileNames[0] : tileNames[1]}); // First capture other player's groups, then your own (if any) - let capd = false; - for (const group of this.deadGroups(this.otherPlayer())) { - capd = true; - for (const cell of group) { - this.board.delete(cell); + if (move.iscapture) { + for (const group of this.deadGroups(this.otherPlayer())) { + for (const cell of group) { + this.board.delete(cell); + } + this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); + this.prison[this.currplayer - 1] += group.size; } - this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); - this.prison[this.currplayer - 1] += group.size; - } - - for (const group of this.deadGroups(this.currplayer)) { - capd = true - for (const cell of group) { - this.board.delete(cell); + + for (const group of this.deadGroups(this.currplayer)) { + for (const cell of group) { + this.board.delete(cell); + } + this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); + this.prison[this.currplayer - 1] += group.size; } - this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); - this.prison[this.currplayer - 1] += group.size; - } - - if (capd) { - this.board.set(cell, [this.neutral, tile]); } if (partial) { return this; } - this.lastmove = m; + this.lastmove = ms; this.currplayer = this.currplayer % 2 + 1 as playerid; this.checkEOG(); this.saveState(); From c935b1ad039d5aeac4421f2ed50082603b80d834 Mon Sep 17 00:00:00 2001 From: th555 Date: Mon, 22 Dec 2025 18:56:38 +0100 Subject: [PATCH 07/18] small fixes, move generation --- src/games/oonpia.ts | 59 +++++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 8c09f4db..5be9be01 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -172,10 +172,20 @@ export class OonpiaGame extends GameBase { const blocked = this.blockedCells(); for (const cell of empties) { if (!blocked[1].has(cell)) { - moves.push("1" + cell); + if (this.isValidPlace(cell, 1)) { + moves.push(this.move2string({tile: 1, iscapture: false, cell: cell})); + } + if (this.isValidCapture(cell, 1)) { + moves.push(this.move2string({tile: 1, iscapture: true, cell: cell})); + } } if (!blocked[2].has(cell)) { - moves.push("2" + cell); + if (this.isValidPlace(cell, 2)) { + moves.push(this.move2string({tile: 2, iscapture: false, cell: cell})); + } + if (this.isValidCapture(cell, 2)) { + moves.push(this.move2string({tile: 2, iscapture: true, cell: cell})); + } } } return moves; @@ -186,16 +196,6 @@ export class OonpiaGame extends GameBase { return moves[Math.floor(Math.random() * moves.length)]; } - private splitTileCell(move: string): [tileid, string] { - // Split the move into tile and cell. - const tile = parseInt(move[0], 10); - const cell = move.slice(1); - if (tile !== 1 && tile !== 2) { - throw new Error(`Invalid tile: ${tile}`); - } - return [tile as tileid, cell]; - } - private preferDotted(cell: string): boolean { /* Check that all adjacent friendly stones are plain, so we want to default to placing a dotted stone next to them */ @@ -299,12 +299,12 @@ export class OonpiaGame extends GameBase { } else { /* Both tiles are valid. If there are friendly neighbouring stones and they are of one type only, default to placing the other type to form a connection. */ - for (let tile of (this.preferDotted(cell) ? [2, 1] : [1, 2]) as tileid[]) { + for (const tile of (this.preferDotted(cell) ? [2, 1] : [1, 2]) as tileid[]) { if (this.isValidPlace(cell, tile)) { place.push(tile); } } - for (let tile of [1, 2] as tileid[]) { + for (const tile of [1, 2] as tileid[]) { if (this.isValidCapture(cell, tile)) { cap.push(tile); } @@ -328,6 +328,8 @@ export class OonpiaGame extends GameBase { possibleMoves.push({tile: tile, iscapture: true, cell: cell}); } + console.log(possibleMoves); + if (possibleMoves.length === 0) { return { valid: false, @@ -401,7 +403,7 @@ export class OonpiaGame extends GameBase { return this.parseMoveString(rest, move); } else { move.iscapture = false; - return this.parseMoveString(m, move); + return this.parseMoveString(ms, move); } } if (move.cell === undefined) { @@ -500,7 +502,7 @@ export class OonpiaGame extends GameBase { if (!result.valid) { throw new UserFacingError("VALIDATION_GENERAL", result.message) } - if (!partial && !this.moves().includes(m)) { + if (!partial && !this.moves().includes(ms)) { throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: ms})) } } @@ -511,10 +513,10 @@ export class OonpiaGame extends GameBase { throw new UserFacingError("VALIDATION_GENERAL", "Invalid movestring encountered."); } - if (move.cell === undefined) { return this; } // Partial move + if (move.cell === undefined || move.tile === undefined) { return this; } // Partial move this.results = []; - this.board.set(move.cell, [this.currplayer, move.tile]); + this.board.set(move.cell, [move.iscapture ? this.neutral : this.currplayer, move.tile]); this.results.push({type: "place", where: move.cell, what: move.tile === 1 ? tileNames[0] : tileNames[1]}); @@ -546,17 +548,17 @@ export class OonpiaGame extends GameBase { return this; } - private pieces(player: playerid): string[] { + private pieces(player: playerid, board = this.board): string[] { // Get all pieces owned by `player` - return [...this.board.entries()].filter(e => (e[1][0] === player)).map(e => e[0]); + return [...board.entries()].filter(e => (e[1][0] === player)).map(e => e[0]); } - private getGroups(player: playerid): Set[] { + private getGroups(player: playerid, board = this.board): Set[] { // In oonpia only alternating types are connected, so dotted pieces only connect with undotted, and vice versa. // Get groups of cells that are connected to `cell` and owned by `player`. const groups: Set[] = []; - const pieces = this.pieces(player); + const pieces = this.pieces(player, board); // /* eslint-disable no-console */ console.log(pieces); const seen: Set = new Set(); for (const piece of pieces) { @@ -573,9 +575,9 @@ export class OonpiaGame extends GameBase { group.add(cell); seen.add(cell); const neighbours = this.graph.neighbours(cell); - const myTile = this.board.get(cell)![1]; + const myTile = board.get(cell)![1]; for (const n of neighbours) { - if (pieces.includes(n) && this.board.get(n)![1] != myTile) { + if (pieces.includes(n) && board.get(n)![1] != myTile) { todo.push(n); } } @@ -644,9 +646,11 @@ export class OonpiaGame extends GameBase { } private deadGroups(player: playerid, board = this.board): Set[] { + console.log("deadgroups", player, board); // Get all groups owned by `player` that are captured. const captured: Set[] = []; - const groups = this.getGroups(player); + const groups = this.getGroups(player, board); + console.log(groups); const blocked = this.blockedCells(board); loop: for (const group of groups) { @@ -663,6 +667,7 @@ export class OonpiaGame extends GameBase { } captured.push(group); } + console.log(captured); return captured; } @@ -670,10 +675,12 @@ export class OonpiaGame extends GameBase { // It's a valid placement of a player (non-blue) stone, i.e. no groups will be captured const tmpboard = new Map(this.board); tmpboard.set(cell, [this.currplayer, tile]); - return ( + const result = ( this.deadGroups(this.otherPlayer(), tmpboard).length === 0 && this.deadGroups(this.currplayer, tmpboard).length === 0 ) + console.log("validplace", result); + return result; } private isValidCapture(cell: string, tile: tileid): boolean { From fa99a727d41826d5a1c10d39c6e4d28bf94494bf Mon Sep 17 00:00:00 2001 From: th555 Date: Mon, 22 Dec 2025 19:51:39 +0100 Subject: [PATCH 08/18] fix isSelfCapture, disable move generation --- src/games/oonpia.ts | 87 ++++++++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 5be9be01..3ffcd40a 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -58,6 +58,7 @@ export class OonpiaGame extends GameBase { apid: "36926ace-08c0-417d-89ec-15346119abf2", }, ], + flags: ["no-moves"], categories: ["mechanic>place", "mechanic>capture", "mechanic>enclose", "board>shape>hex", "board>connect>hex", "components>simple>2per"], variants: [ { uid: "size-5", group: "board" }, @@ -161,40 +162,41 @@ export class OonpiaGame extends GameBase { return this.currplayer === 1 ? 2 : 1; } - public moves(player?: playerid): string[] { - if (this.gameover) { return []; } - if (player === undefined) { - player = this.currplayer; - } - - const moves: string[] = []; - const empties = (this.graph.listCells() as string[]).filter(c => ! this.board.has(c)).sort(); - const blocked = this.blockedCells(); - for (const cell of empties) { - if (!blocked[1].has(cell)) { - if (this.isValidPlace(cell, 1)) { - moves.push(this.move2string({tile: 1, iscapture: false, cell: cell})); - } - if (this.isValidCapture(cell, 1)) { - moves.push(this.move2string({tile: 1, iscapture: true, cell: cell})); - } - } - if (!blocked[2].has(cell)) { - if (this.isValidPlace(cell, 2)) { - moves.push(this.move2string({tile: 2, iscapture: false, cell: cell})); - } - if (this.isValidCapture(cell, 2)) { - moves.push(this.move2string({tile: 2, iscapture: true, cell: cell})); - } - } - } - return moves; - } - - public randomMove(): string { - const moves = this.moves(); - return moves[Math.floor(Math.random() * moves.length)]; - } + /* Too slow */ + // public moves(player?: playerid): string[] { + // if (this.gameover) { return []; } + // if (player === undefined) { + // player = this.currplayer; + // } + + // const moves: string[] = []; + // const empties = (this.graph.listCells() as string[]).filter(c => ! this.board.has(c)).sort(); + // const blocked = this.blockedCells(); + // for (const cell of empties) { + // if (!blocked[1].has(cell)) { + // if (this.isValidPlace(cell, 1)) { + // moves.push(this.move2string({tile: 1, iscapture: false, cell: cell})); + // } + // if (this.isValidCapture(cell, 1)) { + // moves.push(this.move2string({tile: 1, iscapture: true, cell: cell})); + // } + // } + // if (!blocked[2].has(cell)) { + // if (this.isValidPlace(cell, 2)) { + // moves.push(this.move2string({tile: 2, iscapture: false, cell: cell})); + // } + // if (this.isValidCapture(cell, 2)) { + // moves.push(this.move2string({tile: 2, iscapture: true, cell: cell})); + // } + // } + // } + // return moves; + // } + + // public randomMove(): string { + // const moves = this.moves(); + // return moves[Math.floor(Math.random() * moves.length)]; + // } private preferDotted(cell: string): boolean { /* Check that all adjacent friendly stones are plain, so we want to default to placing @@ -328,7 +330,6 @@ export class OonpiaGame extends GameBase { possibleMoves.push({tile: tile, iscapture: true, cell: cell}); } - console.log(possibleMoves); if (possibleMoves.length === 0) { return { @@ -502,9 +503,6 @@ export class OonpiaGame extends GameBase { if (!result.valid) { throw new UserFacingError("VALIDATION_GENERAL", result.message) } - if (!partial && !this.moves().includes(ms)) { - throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: ms})) - } } const move = this.parseMoveString(ms); @@ -559,7 +557,6 @@ export class OonpiaGame extends GameBase { // Get groups of cells that are connected to `cell` and owned by `player`. const groups: Set[] = []; const pieces = this.pieces(player, board); - // /* eslint-disable no-console */ console.log(pieces); const seen: Set = new Set(); for (const piece of pieces) { if (seen.has(piece)) { @@ -646,11 +643,9 @@ export class OonpiaGame extends GameBase { } private deadGroups(player: playerid, board = this.board): Set[] { - console.log("deadgroups", player, board); // Get all groups owned by `player` that are captured. const captured: Set[] = []; const groups = this.getGroups(player, board); - console.log(groups); const blocked = this.blockedCells(board); loop: for (const group of groups) { @@ -667,7 +662,6 @@ export class OonpiaGame extends GameBase { } captured.push(group); } - console.log(captured); return captured; } @@ -679,7 +673,6 @@ export class OonpiaGame extends GameBase { this.deadGroups(this.otherPlayer(), tmpboard).length === 0 && this.deadGroups(this.currplayer, tmpboard).length === 0 ) - console.log("validplace", result); return result; } @@ -697,6 +690,11 @@ export class OonpiaGame extends GameBase { private isSelfCapture(cell: string, tile: tileid): boolean { const tmpboard = new Map(this.board); tmpboard.set(cell, [this.neutral, tile]); + for (const group of this.deadGroups(this.otherPlayer(), tmpboard)) { + for (const cell of group) { + tmpboard.delete(cell); + } + } return this.deadGroups(this.currplayer, tmpboard).length > 0 } @@ -705,7 +703,7 @@ export class OonpiaGame extends GameBase { // Two passes? or just resign? // strictly speaking: """If you move to the prison the last enemy group on the board, you win. Otherwise, if you move to the prison the last friendly group on the board, you lose.""" - // check what asli does + // check what asli does (oh it has unenterable territories) // const prevPlayer = this.currplayer % 2 + 1 as playerid; // if (this.prison[prevPlayer - 1] >= this.threshold) { @@ -941,7 +939,6 @@ export class OonpiaGame extends GameBase { }) } } - console.log("yep"); // Add annotations if (this.stack[this.stack.length - 1]._results.length > 0) { From 9cec6f8abf702bfe81d9740ddc0fe6380c8a2033 Mon Sep 17 00:00:00 2001 From: th555 Date: Mon, 22 Dec 2025 21:37:49 +0100 Subject: [PATCH 09/18] custom random moves --- src/games/oonpia.ts | 90 ++++++++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 3ffcd40a..58b753a6 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -58,7 +58,7 @@ export class OonpiaGame extends GameBase { apid: "36926ace-08c0-417d-89ec-15346119abf2", }, ], - flags: ["no-moves"], + flags: ["no-moves", "custom-randomization"], categories: ["mechanic>place", "mechanic>capture", "mechanic>enclose", "board>shape>hex", "board>connect>hex", "components>simple>2per"], variants: [ { uid: "size-5", group: "board" }, @@ -163,40 +163,58 @@ export class OonpiaGame extends GameBase { } /* Too slow */ - // public moves(player?: playerid): string[] { - // if (this.gameover) { return []; } - // if (player === undefined) { - // player = this.currplayer; - // } - - // const moves: string[] = []; - // const empties = (this.graph.listCells() as string[]).filter(c => ! this.board.has(c)).sort(); - // const blocked = this.blockedCells(); - // for (const cell of empties) { - // if (!blocked[1].has(cell)) { - // if (this.isValidPlace(cell, 1)) { - // moves.push(this.move2string({tile: 1, iscapture: false, cell: cell})); - // } - // if (this.isValidCapture(cell, 1)) { - // moves.push(this.move2string({tile: 1, iscapture: true, cell: cell})); - // } - // } - // if (!blocked[2].has(cell)) { - // if (this.isValidPlace(cell, 2)) { - // moves.push(this.move2string({tile: 2, iscapture: false, cell: cell})); - // } - // if (this.isValidCapture(cell, 2)) { - // moves.push(this.move2string({tile: 2, iscapture: true, cell: cell})); - // } - // } - // } - // return moves; - // } - - // public randomMove(): string { - // const moves = this.moves(); - // return moves[Math.floor(Math.random() * moves.length)]; - // } + public moves(player?: playerid): string[] { + if (this.gameover) { return []; } + if (player === undefined) { + player = this.currplayer; + } + + const moves: string[] = []; + const empties = (this.graph.listCells() as string[]).filter(c => ! this.board.has(c)).sort(); + const blocked = this.blockedCells(); + for (const cell of empties) { + if (!blocked[1].has(cell)) { + if (this.isValidPlace(cell, 1)) { + moves.push(this.move2string({tile: 1, iscapture: false, cell: cell})); + } + if (this.isValidCapture(cell, 1)) { + moves.push(this.move2string({tile: 1, iscapture: true, cell: cell})); + } + } + if (!blocked[2].has(cell)) { + if (this.isValidPlace(cell, 2)) { + moves.push(this.move2string({tile: 2, iscapture: false, cell: cell})); + } + if (this.isValidCapture(cell, 2)) { + moves.push(this.move2string({tile: 2, iscapture: true, cell: cell})); + } + } + } + return moves; + } + + public randomMove(): string { + // Simulate a random click, it's not exhaustive but will give reasonable moves + const blocked = this.blockedCells(); + const empties = new Set((this.graph.listCells() as string[]).filter(c => ( + !this.board.has(c) && + !( + blocked[1].has(c) && + blocked[2].has(c) + ) + ))); + while (empties.size) { + const i = Math.floor(Math.random() * empties.size); + const cell = [...empties][i]; + const [col, row] = this.graph.algebraic2coords(cell); + const clickResult = this.handleClick("", row, col); + if (clickResult.valid) { + return clickResult.move + } + empties.delete(cell); + } + return ""; + } private preferDotted(cell: string): boolean { /* Check that all adjacent friendly stones are plain, so we want to default to placing @@ -593,8 +611,6 @@ export class OonpiaGame extends GameBase { // - For each of these neighbours check whether it forms an arc together with the previous // and/or subsequent neighbours - // TODO caching - const cells = this.graph.listCells() as string[]; const blockedPlain: Set = new Set(); const blockedDotted: Set = new Set(); From 665a6ef6bca5610944ea3bab92ccc3cbeddd6ad3 Mon Sep 17 00:00:00 2001 From: th555 Date: Thu, 25 Dec 2025 16:30:00 +0100 Subject: [PATCH 10/18] game end, all rules implemented --- locales/en/apgames.json | 3 + src/games/oonpia.ts | 265 ++++++++++++++++++++++++++++------------ 2 files changed, 192 insertions(+), 76 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index fefd76b1..fdc70300 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -1664,6 +1664,9 @@ } }, "oonpia": { + "size-4": { + "name": "Size 4 board" + }, "size-5": { "name": "Size 5 board" }, diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 58b753a6..204a01ef 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -1,11 +1,12 @@ /* eslint-disable no-console */ -import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base"; +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IValidationResult } from "./_base"; import { APGamesInformation } from "../schemas/gameinfo"; import { APRenderRep, AreaKey, BoardBasic } from "@abstractplay/renderer/src/schemas/schema"; import { APMoveResult } from "../schemas/moveresults"; import { reviver, UserFacingError } from "../common"; import i18next from "i18next"; import { HexTriGraph } from "../common/graphs"; +import { Glyph } from "@abstractplay/renderer"; export type playerid = 1|2|3; // 3 is neutral export type tileid = 1|2; @@ -18,6 +19,7 @@ type Move = { tile?: tileid; cell?: string; iscapture?: boolean; + pass?: boolean; } export interface IMoveState extends IIndividualState { @@ -58,9 +60,10 @@ export class OonpiaGame extends GameBase { apid: "36926ace-08c0-417d-89ec-15346119abf2", }, ], - flags: ["no-moves", "custom-randomization"], + flags: ["custom-buttons", "no-moves", "custom-randomization"], categories: ["mechanic>place", "mechanic>capture", "mechanic>enclose", "board>shape>hex", "board>connect>hex", "components>simple>2per"], variants: [ + { uid: "size-4", group: "board" }, { uid: "size-5", group: "board" }, { uid: "#board", }, { uid: "size-7", group: "board" }, @@ -162,37 +165,45 @@ export class OonpiaGame extends GameBase { return this.currplayer === 1 ? 2 : 1; } - /* Too slow */ - public moves(player?: playerid): string[] { - if (this.gameover) { return []; } - if (player === undefined) { - player = this.currplayer; + public getButtons(): ICustomButton[] { + /* Only show pass button if there's enemy stones in the prison */ + if (this.prison[this.otherPlayer() - 1] > 0) { + return [{ label: "pass", move: "pass" }]; } - - const moves: string[] = []; - const empties = (this.graph.listCells() as string[]).filter(c => ! this.board.has(c)).sort(); - const blocked = this.blockedCells(); - for (const cell of empties) { - if (!blocked[1].has(cell)) { - if (this.isValidPlace(cell, 1)) { - moves.push(this.move2string({tile: 1, iscapture: false, cell: cell})); - } - if (this.isValidCapture(cell, 1)) { - moves.push(this.move2string({tile: 1, iscapture: true, cell: cell})); - } - } - if (!blocked[2].has(cell)) { - if (this.isValidPlace(cell, 2)) { - moves.push(this.move2string({tile: 2, iscapture: false, cell: cell})); - } - if (this.isValidCapture(cell, 2)) { - moves.push(this.move2string({tile: 2, iscapture: true, cell: cell})); - } - } - } - return moves; + return []; } + /* Too slow */ + // public moves(player?: playerid): string[] { + // if (this.gameover) { return []; } + // if (player === undefined) { + // player = this.currplayer; + // } + + // const moves: string[] = []; + // const empties = (this.graph.listCells() as string[]).filter(c => ! this.board.has(c)).sort(); + // const blocked = this.blockedCells(); + // for (const cell of empties) { + // if (!blocked[1].has(cell)) { + // if (this.isValidPlace(cell, 1)) { + // moves.push(this.move2string({tile: 1, iscapture: false, cell: cell})); + // } + // if (this.isValidCapture(cell, 1)) { + // moves.push(this.move2string({tile: 1, iscapture: true, cell: cell})); + // } + // } + // if (!blocked[2].has(cell)) { + // if (this.isValidPlace(cell, 2)) { + // moves.push(this.move2string({tile: 2, iscapture: false, cell: cell})); + // } + // if (this.isValidCapture(cell, 2)) { + // moves.push(this.move2string({tile: 2, iscapture: true, cell: cell})); + // } + // } + // } + // return moves; + // } + public randomMove(): string { // Simulate a random click, it's not exhaustive but will give reasonable moves const blocked = this.blockedCells(); @@ -213,7 +224,11 @@ export class OonpiaGame extends GameBase { } empties.delete(cell); } - return ""; + if (this.prison[this.otherPlayer() - 1] > 0) { + return "pass" + } else { + return ""; + } } private preferDotted(cell: string): boolean { @@ -252,6 +267,7 @@ export class OonpiaGame extends GameBase { if (row === -1) { /* Always reset if player clicks the legend */ + /* Handles both piece selection and passing (clicking the prison) */ if (piece) { move = this.parseMoveString(piece); if (move === undefined) { @@ -402,6 +418,9 @@ export class OonpiaGame extends GameBase { if (ms === "" || ms === undefined) { return move; } + if (ms === "pass") { + return {pass: true}; + } if (move.tile === undefined) { const ts = ms.slice(0, 1); const rest = ms.slice(1); @@ -438,7 +457,11 @@ export class OonpiaGame extends GameBase { } private move2string(move: Move): string { - return `${move.tile || ''}${move.iscapture ? 'X' : ''}${move.cell || ''}` + if (move.pass === true) { + return "pass"; + } else { + return `${move.tile || ''}${move.iscapture ? 'X' : ''}${move.cell || ''}` + } } public validateMove(m: string): IValidationResult { @@ -449,6 +472,21 @@ export class OonpiaGame extends GameBase { message: i18next.t("apgames:validation._general.INVALID_MOVE", { move: m }) } } + if (move.pass === true) { + if (this.prison[this.otherPlayer() - 1] === 0) { + return { + valid: false, + message: i18next.t("apgames:validation.asli.BAD_PASS") + } + } else { + return { + valid: true, + complete: 1, + canrender: true, + message: i18next.t("apgames:validation._general.VALID_MOVE"), + } + } + } if (move.tile === undefined) { return { valid: true, @@ -529,32 +567,39 @@ export class OonpiaGame extends GameBase { throw new UserFacingError("VALIDATION_GENERAL", "Invalid movestring encountered."); } - if (move.cell === undefined || move.tile === undefined) { return this; } // Partial move + if (move.pass === true) { + this.prison[this.otherPlayer() - 1] -= 1; + this.results.push({type: "pass"}); + } else { + if (move.cell === undefined || move.tile === undefined) { return this; } // Partial move - this.results = []; - this.board.set(move.cell, [move.iscapture ? this.neutral : this.currplayer, move.tile]); - this.results.push({type: "place", where: move.cell, what: move.tile === 1 ? tileNames[0] : tileNames[1]}); + this.results = []; + this.board.set(move.cell, [move.iscapture ? this.neutral : this.currplayer, move.tile]); + this.results.push({type: "place", where: move.cell, what: move.tile === 1 ? tileNames[0] : tileNames[1]}); - - // First capture other player's groups, then your own (if any) - if (move.iscapture) { - for (const group of this.deadGroups(this.otherPlayer())) { - for (const cell of group) { - this.board.delete(cell); - } - this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); - this.prison[this.currplayer - 1] += group.size; - } - for (const group of this.deadGroups(this.currplayer)) { - for (const cell of group) { - this.board.delete(cell); + // First capture other player's groups, then your own (if any) + if (move.iscapture) { + for (const group of this.deadGroups(this.otherPlayer())) { + for (const cell of group) { + this.board.delete(cell); + } + this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); + this.prison[this.otherPlayer() - 1] += group.size; + } + + for (const group of this.deadGroups(this.currplayer)) { + for (const cell of group) { + this.board.delete(cell); + } + this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); + this.prison[this.currplayer - 1] += group.size; } - this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); - this.prison[this.currplayer - 1] += group.size; } } + this.reducePrison(); + if (partial) { return this; } this.lastmove = ms; @@ -714,25 +759,33 @@ export class OonpiaGame extends GameBase { return this.deadGroups(this.currplayer, tmpboard).length > 0 } - protected checkEOG(): OonpiaGame { - // TODO - - // Two passes? or just resign? - // strictly speaking: """If you move to the prison the last enemy group on the board, you win. Otherwise, if you move to the prison the last friendly group on the board, you lose.""" - // check what asli does (oh it has unenterable territories) + private reducePrison(): void { + const min = Math.min(...this.prison); + this.prison = this.prison.map(n => n - min) as [number,number]; + } - // const prevPlayer = this.currplayer % 2 + 1 as playerid; - // if (this.prison[prevPlayer - 1] >= this.threshold) { - // this.gameover = true; - // this.winner = [prevPlayer]; - // } + protected checkEOG(): OonpiaGame { + // Bluestone rules: "If you move to the prison the last enemy group on + // the board, you win. Otherwise, if you move to the prison the last + // friendly group on the board, you lose." + if (this.stack.length > 2) { + const friendly = this.pieces(this.currplayer).length; + const enemy = this.pieces(this.otherPlayer()).length; + if (enemy === 0) { + this.gameover = true; + this.winner = [this.currplayer]; + } else if (friendly === 0) { + this.gameover = true; + this.winner = [this.otherPlayer()]; + } + } - // if (this.gameover) { - // this.results.push( - // {type: "eog"}, - // {type: "winners", players: [...this.winner]} - // ); - // } + if (this.gameover) { + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } return this; } @@ -799,6 +852,32 @@ export class OonpiaGame extends GameBase { const boardcol = "#e0bb6c"; // colours from besogo viewer together with #252525, #eeeeee and #0165fc const boardEdgeW = 55; + const hasPrison = this.prison.reduce((prev, curr) => prev + curr, 0) > 0; + const prisonPiece: Glyph[] = []; + if (hasPrison) { + prisonPiece.push({ + name: "piece-borderless", + colour: this.prison[0] > 0 ? 1 : 2, + scale: 0.85, + }); + prisonPiece.push({ + text: this.prison[0] > 0 ? this.prison[0].toString() : this.prison[1].toString(), + colour: { + func: "bestContrast", + bg: this.prison[0] > 0 ? 1 : 2, + fg: ["#000000", "#ffffff"], + }, + scale: 0.7, + rotate: null, + }); + } else { + prisonPiece.push({ + name: "piece-borderless", + colour: "_context_background", + scale: 0.85, + }); + } + // Build rep const rep: APRenderRep = { board: { @@ -902,16 +981,48 @@ export class OonpiaGame extends GameBase { fg: ["#000000", "#ffffff"], }, scale: 0.363, opacity: 0.5} ], - E: {name: "piece-borderless", colour: 3, scale: 1.1}, + E: {name: "piece-borderless", colour: 3, scale: 1.0}, F: [ - {name: "piece-borderless", colour: 3, scale: 1.1}, + {name: "piece-borderless", colour: 3, scale: 1.0}, {name: "piece-borderless", colour: { func: "bestContrast", bg: 3, fg: ["#000000", "#ffffff"], - }, scale: 0.363, opacity: 0.5} + }, scale: 0.33, opacity: 0.5} ], - + G: {name: "piece-borderless", colour: 1, scale: 1.0}, // G-K are just for the key + H: [ + {name: "piece-borderless", colour: 1, scale: 1.0}, + {name: "piece-borderless", colour: { + func: "bestContrast", + bg: 1, + fg: ["#000000", "#ffffff"], + }, scale: 0.33, opacity: 0.5} + ], + I: {name: "piece-borderless", colour: 2, scale: 1.0}, + J: [ + {name: "piece-borderless", colour: 2, scale: 1.0}, + {name: "piece-borderless", colour: { + func: "bestContrast", + bg: 2, + fg: ["#000000", "#ffffff"], + }, scale: 0.33, opacity: 0.5} + ], + K: {name: "piece-borderless", colour: 3, scale: 1.0}, + L: [ + {name: "piece-borderless", colour: 3, scale: 1.0}, + {name: "piece-borderless", colour: { + func: "bestContrast", + bg: 3, + fg: ["#000000", "#ffffff"], + }, scale: 0.33, opacity: 0.5} + ], + P: prisonPiece as [Glyph, ...Glyph[]], + Q: { + name: "piece-borderless", + colour: "_context_background", + scale: 0.85, + } }, pieces: pstr.map(p => p.join("")).join("\n"), }; @@ -922,10 +1033,12 @@ export class OonpiaGame extends GameBase { position: "left", height: 0.7, list: [ - { piece: this.currplayer === 1 ? "A" : "C", name: "", value: "1" }, - { piece: this.currplayer === 1 ? "B" : "D", name: "", value: "2" }, - { piece: "E", name: "", value: "1X" }, - { piece: "F", name: "", value: "2X" }, + { piece: "P", name: "", value: "pass"}, + { piece: "Q", name: ""}, + { piece: this.currplayer === 1 ? "G" : "I", name: "", value: "1" }, + { piece: this.currplayer === 1 ? "H" : "J", name: "", value: "2" }, + { piece: "K", name: "", value: "1X" }, + { piece: "L", name: "", value: "2X" }, ], clickable: true, }; From 41eb681a48300676bb40045b5147741354f8dde7 Mon Sep 17 00:00:00 2001 From: th555 Date: Sun, 28 Dec 2025 23:32:51 +0100 Subject: [PATCH 11/18] custom colours, komi pie using builtin pie --- locales/en/apgames.json | 20 +++- src/games/oonpia.ts | 236 ++++++++++++++++++++++++++++------------ 2 files changed, 185 insertions(+), 71 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index fdc70300..e0f16e66 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -2859,6 +2859,20 @@ "name": "Hide threatened" } }, + "oonpia": { + "palette_no_blocked_no": { + "description": "Black/white/blue colours, don't highlight blocked cells", + "name": "palette_no_blocked_no" + }, + "palette_yes_blocked_yes": { + "description": "Palette colours, highlight blocked cells.", + "name": "palette_yes_blocked_yes" + }, + "palette_yes_blocked_no": { + "description": "Palette colours, don't highlight blocked cells.", + "name": "palette_yes_blocked_no" + } + }, "oust": { "hide-moves": { "description": "Don't show possible moves.", @@ -4531,11 +4545,13 @@ }, "oonpia": { "DESTINATION": "Select a cell to place the piece.", - "INITIAL_INSTRUCTIONS": "Place 1 piece of either type on the board.", + "INITIAL_INSTRUCTIONS": "Place a piece of either type on an empty space, or pass to take an enemy piece out of the prison instead (if one is available). Either select a piece from the legend, or click on the board to cycle through the available pieces.", + "INITIAL_INSTRUCTIONS_komi": "Komi pie offer: enter the number of pieces to place in the prison as a compensation for not playing the first stone. The other player will then decide which seat to take.", "ARC": "You may not form a size 4 arc of stones of one type, regardless of colour.", "INVALID_CAPTURE": "A blue stone must always capture some enemy or friendly stones.", "INVALID_PLACE": "Placing a stone of your own colour may not capture any stones.", - "INVALID_BOTH": "Neither a placement nor a capture is possible at {{where}}." + "INVALID_BOTH": "Neither a placement nor a capture is possible at {{where}}.", + "BAD_KOMI": "The first move of the game must be a non-negative integer representing the number of pieces to place in the starting prison." }, "orb": { "INITIAL_INSTRUCTIONS": "Select one of your own pieces to move.", diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 204a01ef..d5040881 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -1,9 +1,9 @@ /* eslint-disable no-console */ -import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IValidationResult } from "./_base"; +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IRenderOpts, IValidationResult } from "./_base"; import { APGamesInformation } from "../schemas/gameinfo"; import { APRenderRep, AreaKey, BoardBasic } from "@abstractplay/renderer/src/schemas/schema"; import { APMoveResult } from "../schemas/moveresults"; -import { reviver, UserFacingError } from "../common"; +import { randomInt, reviver, UserFacingError } from "../common"; import i18next from "i18next"; import { HexTriGraph } from "../common/graphs"; import { Glyph } from "@abstractplay/renderer"; @@ -20,6 +20,7 @@ type Move = { cell?: string; iscapture?: boolean; pass?: boolean; + komi?: number; } export interface IMoveState extends IIndividualState { @@ -60,7 +61,7 @@ export class OonpiaGame extends GameBase { apid: "36926ace-08c0-417d-89ec-15346119abf2", }, ], - flags: ["custom-buttons", "no-moves", "custom-randomization"], + flags: ["pie", "custom-buttons", "no-moves", "custom-randomization", "custom-colours"], categories: ["mechanic>place", "mechanic>capture", "mechanic>enclose", "board>shape>hex", "board>connect>hex", "components>simple>2per"], variants: [ { uid: "size-4", group: "board" }, @@ -72,6 +73,11 @@ export class OonpiaGame extends GameBase { { uid: "size-10", group: "board" }, { uid: "size-11", group: "board" }, { uid: "size-12", group: "board" } + ], + displays: [ // default: palette_no_blocked_yes + {uid: "palette_no_blocked_no"}, + {uid: "palette_yes_blocked_yes"}, + {uid: "palette_yes_blocked_no"}, ] }; @@ -87,6 +93,7 @@ export class OonpiaGame extends GameBase { public results: Array = []; public prison: [number, number] = [0, 0]; private boardSize = 0; + private usePalette = false; constructor(state?: IOonpiaState | string, variants?: string[]) { super(); @@ -205,6 +212,9 @@ export class OonpiaGame extends GameBase { // } public randomMove(): string { + if (this.stack.length === 1) { + return randomInt(10).toString(); + } // Simulate a random click, it's not exhaustive but will give reasonable moves const blocked = this.blockedCells(); const empties = new Set((this.graph.listCells() as string[]).filter(c => ( @@ -256,6 +266,14 @@ export class OonpiaGame extends GameBase { } public handleClick(ms: string, row: number, col: number, piece?: string): IClickResult { + if (this.stack.length === 1) { + return { + move: "", + valid: true, + message: i18next.t("apgames:validation.oonpia.BAD_KOMI", ms) + }; + } + let move = this.parseMoveString(ms); if (move === undefined) { return { @@ -410,6 +428,14 @@ export class OonpiaGame extends GameBase { } } + private parseKomiString(ms: string): Move | undefined { + const komi = parseInt(ms); + if (isNaN(komi) || komi < 0 || (! /^\d+$/.test(ms)) || komi > this.graph.graph.nodes().length) { + return undefined; + } + return {komi: komi}; + } + private parseMoveString(ms: string, move: Move = {}): Move | undefined { // the stone type (1 or 2) comes before the coordinate, e.g. 1c3 or 2c3 // After that an 'X' if it's a capture using neutral stone, e.g. 1Xc3, 2Xc3 @@ -465,6 +491,30 @@ export class OonpiaGame extends GameBase { } public validateMove(m: string): IValidationResult { + if (this.stack.length === 1) { // Make a komi offer + if (m === "") { + return { + valid: true, + complete: -1, + message: i18next.t("apgames:validation.oonpia.INITIAL_INSTRUCTIONS_komi"), + } + } + const move = this.parseKomiString(m); + if (move === undefined) { + return { + valid: false, + message: i18next.t("apgames:validation.oonpia.BAD_KOMI") + } + } else { + return { + valid: true, + complete: 1, + canrender: true, + message: i18next.t("apgames:validation._general.VALID_MOVE"), + } + } + } + const move = this.parseMoveString(m); if (move === undefined) { return { @@ -560,40 +610,50 @@ export class OonpiaGame extends GameBase { throw new UserFacingError("VALIDATION_GENERAL", result.message) } } - - const move = this.parseMoveString(ms); - - if (move === undefined) { - throw new UserFacingError("VALIDATION_GENERAL", "Invalid movestring encountered."); - } - if (move.pass === true) { - this.prison[this.otherPlayer() - 1] -= 1; - this.results.push({type: "pass"}); + if (this.stack.length === 1) { // Make komi offer + const move = this.parseKomiString(ms); + if (move === undefined) { + throw new UserFacingError("VALIDATION_GENERAL", "Invalid movestring encountered."); + } + if (move.komi === undefined) { return this; } // Partial move + this.prison[1] = move.komi; + this.results.push({type: "komi", value: move.komi}); } else { - if (move.cell === undefined || move.tile === undefined) { return this; } // Partial move + const move = this.parseMoveString(ms); + + if (move === undefined) { + throw new UserFacingError("VALIDATION_GENERAL", "Invalid movestring encountered."); + } - this.results = []; - this.board.set(move.cell, [move.iscapture ? this.neutral : this.currplayer, move.tile]); - this.results.push({type: "place", where: move.cell, what: move.tile === 1 ? tileNames[0] : tileNames[1]}); + if (move.pass === true) { + this.prison[this.otherPlayer() - 1] -= 1; + this.results.push({type: "pass"}); + } else { + if (move.cell === undefined || move.tile === undefined) { return this; } // Partial move + + this.results = []; + this.board.set(move.cell, [move.iscapture ? this.neutral : this.currplayer, move.tile]); + this.results.push({type: "place", where: move.cell, what: move.tile === 1 ? tileNames[0] : tileNames[1]}); - - // First capture other player's groups, then your own (if any) - if (move.iscapture) { - for (const group of this.deadGroups(this.otherPlayer())) { - for (const cell of group) { - this.board.delete(cell); - } - this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); - this.prison[this.otherPlayer() - 1] += group.size; - } - for (const group of this.deadGroups(this.currplayer)) { - for (const cell of group) { - this.board.delete(cell); + // First capture other player's groups, then your own (if any) + if (move.iscapture) { + for (const group of this.deadGroups(this.otherPlayer())) { + for (const cell of group) { + this.board.delete(cell); + } + this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); + this.prison[this.otherPlayer() - 1] += group.size; + } + + for (const group of this.deadGroups(this.currplayer)) { + for (const cell of group) { + this.board.delete(cell); + } + this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); + this.prison[this.currplayer - 1] += group.size; } - this.results.push({type: "capture", where: Array.from(group).join(","), count: group.size}); - this.prison[this.currplayer - 1] += group.size; } } } @@ -813,7 +873,42 @@ export class OonpiaGame extends GameBase { }; } - public render(): APRenderRep { + + public getPlayerColour(p: playerid): number|string { + // previous versions, just return the player id + if (this.usePalette) { + return p; + } else { + return {1: "#eeeeee", 2: "#252525", 3: "#0165fc"}[p] // colours from besogo viewer + } + } + + public render(opts?: IRenderOpts): APRenderRep { + let altDisplay: string | undefined; + if (opts !== undefined) { + altDisplay = opts.altDisplay; + } + let highlightBlocked = true; + if (altDisplay !== undefined) { + if (altDisplay === "palette_no_blocked_no") { + this.usePalette = false; + highlightBlocked = false; + } + if (altDisplay === "palette_yes_blocked_yes") { + this.usePalette = true; + highlightBlocked = true; + } + if (altDisplay === "palette_yes_blocked_no") { + this.usePalette = true; + highlightBlocked = false; + } + } + console.log(highlightBlocked); + + const p1 = this.getPlayerColour(1); + const p2 = this.getPlayerColour(2); + const p3 = this.getPlayerColour(3); + // Build piece string const pstr: string[][] = []; const cells = this.graph.listCells(true); @@ -849,7 +944,7 @@ export class OonpiaGame extends GameBase { } const s = this.boardSize - 1; - const boardcol = "#e0bb6c"; // colours from besogo viewer together with #252525, #eeeeee and #0165fc + const boardcol = "#e0bb6c"; // colours from besogo viewer together with const boardEdgeW = 55; const hasPrison = this.prison.reduce((prev, curr) => prev + curr, 0) > 0; @@ -857,14 +952,14 @@ export class OonpiaGame extends GameBase { if (hasPrison) { prisonPiece.push({ name: "piece-borderless", - colour: this.prison[0] > 0 ? 1 : 2, + colour: this.prison[0] > 0 ? p1 : p2, scale: 0.85, }); prisonPiece.push({ text: this.prison[0] > 0 ? this.prison[0].toString() : this.prison[1].toString(), colour: { func: "bestContrast", - bg: this.prison[0] > 0 ? 1 : 2, + bg: this.prison[0] > 0 ? p1 : p2, fg: ["#000000", "#ffffff"], }, scale: 0.7, @@ -963,57 +1058,57 @@ export class OonpiaGame extends GameBase { ] }, legend: { - A: {name: "piece-borderless", colour: 1, scale: 1.1}, + A: {name: "piece-borderless", colour: p1, scale: 1.1}, B: [ - {name: "piece-borderless", colour: 1, scale: 1.1}, + {name: "piece-borderless", colour: p1, scale: 1.1}, {name: "piece-borderless", colour: { func: "bestContrast", - bg: 1, + bg: p1, fg: ["#000000", "#ffffff"], }, scale: 0.363, opacity: 0.5} ], - C: {name: "piece-borderless", colour: 2, scale: 1.1}, + C: {name: "piece-borderless", colour: p2, scale: 1.1}, D: [ - {name: "piece-borderless", colour: 2, scale: 1.1}, + {name: "piece-borderless", colour: p2, scale: 1.1}, {name: "piece-borderless", colour: { func: "bestContrast", - bg: 2, + bg: p2, fg: ["#000000", "#ffffff"], }, scale: 0.363, opacity: 0.5} ], - E: {name: "piece-borderless", colour: 3, scale: 1.0}, + E: {name: "piece-borderless", colour: p3, scale: 1.0}, F: [ - {name: "piece-borderless", colour: 3, scale: 1.0}, + {name: "piece-borderless", colour: p3, scale: 1.0}, {name: "piece-borderless", colour: { func: "bestContrast", - bg: 3, + bg: p3, fg: ["#000000", "#ffffff"], }, scale: 0.33, opacity: 0.5} ], - G: {name: "piece-borderless", colour: 1, scale: 1.0}, // G-K are just for the key + G: {name: "piece-borderless", colour: p1, scale: 1.0}, // G-K are just for the key H: [ - {name: "piece-borderless", colour: 1, scale: 1.0}, + {name: "piece-borderless", colour: p1, scale: 1.0}, {name: "piece-borderless", colour: { func: "bestContrast", - bg: 1, + bg: p1, fg: ["#000000", "#ffffff"], }, scale: 0.33, opacity: 0.5} ], - I: {name: "piece-borderless", colour: 2, scale: 1.0}, + I: {name: "piece-borderless", colour: p2, scale: 1.0}, J: [ - {name: "piece-borderless", colour: 2, scale: 1.0}, + {name: "piece-borderless", colour: p2, scale: 1.0}, {name: "piece-borderless", colour: { func: "bestContrast", - bg: 2, + bg: p2, fg: ["#000000", "#ffffff"], }, scale: 0.33, opacity: 0.5} ], - K: {name: "piece-borderless", colour: 3, scale: 1.0}, + K: {name: "piece-borderless", colour: p3, scale: 1.0}, L: [ - {name: "piece-borderless", colour: 3, scale: 1.0}, + {name: "piece-borderless", colour: p3, scale: 1.0}, {name: "piece-borderless", colour: { func: "bestContrast", - bg: 3, + bg: p3, fg: ["#000000", "#ffffff"], }, scale: 0.33, opacity: 0.5} ], @@ -1027,22 +1122,25 @@ export class OonpiaGame extends GameBase { pieces: pstr.map(p => p.join("")).join("\n"), }; - // Add key so the user can click to select the color to place - const key: AreaKey = { - type: "key", - position: "left", - height: 0.7, - list: [ - { piece: "P", name: "", value: "pass"}, - { piece: "Q", name: ""}, - { piece: this.currplayer === 1 ? "G" : "I", name: "", value: "1" }, - { piece: this.currplayer === 1 ? "H" : "J", name: "", value: "2" }, - { piece: "K", name: "", value: "1X" }, - { piece: "L", name: "", value: "2X" }, - ], - clickable: true, - }; - rep.areas = [key]; + if (this.stack.length !== 1) { + // Add key so the user can click to select the color to place + const key: AreaKey = { + type: "key", + position: "left", + height: 0.7, + list: [ + { piece: "P", name: "", value: "pass"}, + { piece: "Q", name: ""}, + { piece: this.currplayer === 1 ? "G" : "I", name: "", value: "1" }, + { piece: this.currplayer === 1 ? "H" : "J", name: "", value: "2" }, + { piece: "K", name: "", value: "1X" }, + { piece: "L", name: "", value: "2X" }, + ], + clickable: true, + }; + rep.areas = [key]; + } + const {1: blockedPlain, 2: blockedDotted} = this.blockedCells(); for (const cell of blockedPlain) { const [x, y] = this.graph.algebraic2coords(cell); From 4fc6a55b60ef88b9ba6bfdc4e393a1b6efe8c14f Mon Sep 17 00:00:00 2001 From: th555 Date: Mon, 29 Dec 2025 19:11:48 +0100 Subject: [PATCH 12/18] display customization --- src/games/oonpia.ts | 47 +++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index d5040881..409769ba 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -903,7 +903,6 @@ export class OonpiaGame extends GameBase { highlightBlocked = false; } } - console.log(highlightBlocked); const p1 = this.getPlayerColour(1); const p2 = this.getPlayerColour(2); @@ -1141,29 +1140,31 @@ export class OonpiaGame extends GameBase { rep.areas = [key]; } - const {1: blockedPlain, 2: blockedDotted} = this.blockedCells(); - for (const cell of blockedPlain) { - const [x, y] = this.graph.algebraic2coords(cell); - if ("markers" in (rep.board! as BoardBasic)) { // make the compiler happy - ((rep.board! as BoardBasic).markers!).push({ - type: "dots", - points: [{row: y, col: x}], - colour: "#000", - opacity: 0.2, - size: 0.3 - }) + if (highlightBlocked) { + const {1: blockedPlain, 2: blockedDotted} = this.blockedCells(); + for (const cell of blockedPlain) { + const [x, y] = this.graph.algebraic2coords(cell); + if ("markers" in (rep.board! as BoardBasic)) { // make the compiler happy + ((rep.board! as BoardBasic).markers!).push({ + type: "dots", + points: [{row: y, col: x}], + colour: "#000", + opacity: 0.2, + size: 0.3 + }) + } } - } - for (const cell of blockedDotted) { - const [x, y] = this.graph.algebraic2coords(cell); - if ("markers" in (rep.board! as BoardBasic)) { // make the compiler happy - ((rep.board! as BoardBasic).markers!).push({ - type: "dots", - points: [{row: y, col: x}], - colour: "#000", - opacity: 0.2, - size: 0.9 - }) + for (const cell of blockedDotted) { + const [x, y] = this.graph.algebraic2coords(cell); + if ("markers" in (rep.board! as BoardBasic)) { // make the compiler happy + ((rep.board! as BoardBasic).markers!).push({ + type: "dots", + points: [{row: y, col: x}], + colour: "#000", + opacity: 0.2, + size: 0.9 + }) + } } } From 6d2aea704743c68cd88327bbf1044ec73d192975 Mon Sep 17 00:00:00 2001 From: th555 Date: Thu, 8 Jan 2026 14:59:43 +0100 Subject: [PATCH 13/18] oonpia wip --- src/games/oonpia.ts | 48 ++++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 409769ba..d7f6cbda 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -328,7 +328,21 @@ export class OonpiaGame extends GameBase { // piece was clicked again. const place: tileid[] = []; - const cap: tileid[] = []; + const otherCap: tileid[] = []; + const selfAndOtherCap: tileid[] = []; + const selfCap: tileid[] = []; + + const addCaps = (tile: tileid) => { + const self = this.isSelfCapture(cell, tile); + const other = this.isOtherCapture(cell, tile); + if (other && !self) { + otherCap.push(tile); + } else if (other && self) { + selfAndOtherCap.push(tile); + } else if (!other && self) { + selfCap.push(tile); + } + } // First the normal placements: // - If a cell is blocked for one type by the arc rule, only the other type is valid @@ -340,15 +354,13 @@ export class OonpiaGame extends GameBase { if (this.isValidPlace(cell, 2)) { place.push(2); } - if (this.isValidCapture(cell, 2)) { - cap.push(2); - } + addCaps(2); } else if (blocked[2].has(cell) && !blocked[1].has(cell)) { if (this.isValidPlace(cell, 1)) { place.push(1); } if (this.isValidCapture(cell, 1)) { - cap.push(1); + addCaps(1); } } else { /* Both tiles are valid. If there are friendly neighbouring stones and they are @@ -360,25 +372,23 @@ export class OonpiaGame extends GameBase { } for (const tile of [1, 2] as tileid[]) { if (this.isValidCapture(cell, tile)) { - cap.push(tile); + addCaps(tile); } } } - // Capturing placements: - // - If one includes self-capture and the other doesn't, prefer the latter - if (cap.length === 2 - && this.isSelfCapture(cell, cap[0]) - && !this.isSelfCapture(cell, cap[1]) - ) { - [cap[0], cap[1]] = [cap[1], cap[0]]; - } - + // Ordering: const possibleMoves: Move[] = []; + for (const tile of otherCap) { + possibleMoves.push({tile: tile, iscapture: true, cell: cell}); + } + for (const tile of selfAndOtherCap) { + possibleMoves.push({tile: tile, iscapture: true, cell: cell}); + } for (const tile of place) { possibleMoves.push({tile: tile, iscapture: false, cell: cell}); } - for (const tile of cap) { + for (const tile of selfCap) { possibleMoves.push({tile: tile, iscapture: true, cell: cell}); } @@ -819,6 +829,12 @@ export class OonpiaGame extends GameBase { return this.deadGroups(this.currplayer, tmpboard).length > 0 } + private isOtherCapture(cell: string, tile: tileid): boolean { + const tmpboard = new Map(this.board); + tmpboard.set(cell, [this.neutral, tile]); + return this.deadGroups(this.otherPlayer(), tmpboard).length > 0 + } + private reducePrison(): void { const min = Math.min(...this.prison); this.prison = this.prison.map(n => n - min) as [number,number]; From c89a9add925c897f99b80ddfd030589521836c4b Mon Sep 17 00:00:00 2001 From: th555 Date: Sat, 21 Feb 2026 17:01:44 +0100 Subject: [PATCH 14/18] small tweaks --- locales/en/apgames.json | 3 --- package-lock.json | 8 ++++---- package.json | 2 +- src/games/oonpia.ts | 9 ++++----- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index cb3543c0..fdff2c67 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -1731,9 +1731,6 @@ } }, "oonpia": { - "size-4": { - "name": "Size 4 board" - }, "size-5": { "name": "Size 5 board" }, diff --git a/package-lock.json b/package-lock.json index bcba3a25..b78396f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@abstractplay/recranks": "latest", - "@abstractplay/renderer": "^1.0.0-ci-20820435130.0", + "@abstractplay/renderer": "^1.0.0-ci-20966259367.0", "@turf/boolean-contains": "^6.5.0", "@turf/boolean-intersects": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", @@ -119,9 +119,9 @@ } }, "node_modules/@abstractplay/renderer": { - "version": "1.0.0-ci-20820435130.0", - "resolved": "https://npm.pkg.github.com/download/@abstractplay/renderer/1.0.0-ci-20820435130.0/f8db938bfd7cf36746be377af38ae44c6a42581c", - "integrity": "sha512-r47DLTSP+Md5Utw0pqOVG98SRjJBf35Bn4ttzQNXuufKOIjf+maiy2VvSi1yUaP7TKxVeAQLQf5pt8A7C5noGQ==", + "version": "1.0.0-ci-20966259367.0", + "resolved": "https://npm.pkg.github.com/download/@abstractplay/renderer/1.0.0-ci-20966259367.0/95520e6d42d42b3929e28f4df0c9809b6902906d", + "integrity": "sha512-Nf8cqqBPwM+BctDyS+evwWq5Gvre1RS8mmtCsJwnXVnGx9UmtsUp2ebtNhNuujz0E54kWpduwq+5qQJcsRM2aw==", "license": "MIT", "dependencies": { "@svgdotjs/svg.js": "3.2.0", diff --git a/package.json b/package.json index 30f2dae5..4359d65b 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ }, "dependencies": { "@abstractplay/recranks": "latest", - "@abstractplay/renderer": "^1.0.0-ci-20820435130.0", + "@abstractplay/renderer": "^1.0.0-ci-20966259367.0", "@turf/boolean-contains": "^6.5.0", "@turf/boolean-intersects": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index d7f6cbda..6368cb5a 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -64,7 +64,6 @@ export class OonpiaGame extends GameBase { flags: ["pie", "custom-buttons", "no-moves", "custom-randomization", "custom-colours"], categories: ["mechanic>place", "mechanic>capture", "mechanic>enclose", "board>shape>hex", "board>connect>hex", "components>simple>2per"], variants: [ - { uid: "size-4", group: "board" }, { uid: "size-5", group: "board" }, { uid: "#board", }, { uid: "size-7", group: "board" }, @@ -396,7 +395,7 @@ export class OonpiaGame extends GameBase { if (possibleMoves.length === 0) { return { valid: false, - message: i18next.t("apgames:validation.oonpia.INVALID_BOTH", {where: move.cell}), + message: i18next.t("apgames:validation.oonpia.INVALID_BOTH", {where: ms}), move: ms } } @@ -959,7 +958,7 @@ export class OonpiaGame extends GameBase { } const s = this.boardSize - 1; - const boardcol = "#e0bb6c"; // colours from besogo viewer together with + const boardcol = this.usePalette ? 4 : "#e0bb6c"; const boardEdgeW = 55; const hasPrison = this.prison.reduce((prev, curr) => prev + curr, 0) > 0; @@ -1091,9 +1090,9 @@ export class OonpiaGame extends GameBase { fg: ["#000000", "#ffffff"], }, scale: 0.363, opacity: 0.5} ], - E: {name: "piece-borderless", colour: p3, scale: 1.0}, + E: {name: "piece-borderless", colour: p3, scale: 1.1}, F: [ - {name: "piece-borderless", colour: p3, scale: 1.0}, + {name: "piece-borderless", colour: p3, scale: 1.1}, {name: "piece-borderless", colour: { func: "bestContrast", bg: p3, From 95f2101f4b272f09e01b9e9d42f38514d1097a5f Mon Sep 17 00:00:00 2001 From: th555 Date: Sat, 21 Feb 2026 17:06:36 +0100 Subject: [PATCH 15/18] experimental --- src/games/oonpia.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 6368cb5a..c93559e0 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -61,7 +61,7 @@ export class OonpiaGame extends GameBase { apid: "36926ace-08c0-417d-89ec-15346119abf2", }, ], - flags: ["pie", "custom-buttons", "no-moves", "custom-randomization", "custom-colours"], + flags: ["experimental", "pie", "custom-buttons", "no-moves", "custom-randomization", "custom-colours"], categories: ["mechanic>place", "mechanic>capture", "mechanic>enclose", "board>shape>hex", "board>connect>hex", "components>simple>2per"], variants: [ { uid: "size-5", group: "board" }, From cd6f033736b22faae295ebba23058d111084d87b Mon Sep 17 00:00:00 2001 From: th555 Date: Sun, 22 Feb 2026 13:48:37 +0100 Subject: [PATCH 16/18] fix msg --- package-lock.json | 25 +++++-------------------- package.json | 2 +- src/games/oonpia.ts | 3 +-- 3 files changed, 7 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb885f1f..ce5a7c5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@abstractplay/recranks": "latest", - "@abstractplay/renderer": "^1.0.0-ci-21051058249.0", + "@abstractplay/renderer": "^1.0.0-ci-22152124889.0", "@turf/boolean-contains": "^6.5.0", "@turf/boolean-intersects": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", @@ -119,9 +119,9 @@ } }, "node_modules/@abstractplay/renderer": { - "version": "1.0.0-ci-21051058249.0", - "resolved": "https://npm.pkg.github.com/download/@abstractplay/renderer/1.0.0-ci-21051058249.0/33324f6dff480357f4f48a553b11ab10c9a904b8", - "integrity": "sha512-WmyYLmGol/fqY5XwLvq3ntFtMcsxHlzKlCtuYS7cMvx+D87WvGMP7/V3y3ktb4/BT0gXX4Zrmgv4YGoT3eZC6w==", + "version": "1.0.0-ci-22152124889.0", + "resolved": "https://npm.pkg.github.com/download/@abstractplay/renderer/1.0.0-ci-22152124889.0/c8156d6c53fcc7bbcf23aa7d30cefb5e35c9d11b", + "integrity": "sha512-tMBanfZ5cvdH1TFayQd6X0Fmjgka+gX2M+smigDw8Sxo6YPBphEIx3Z5P5aigCHeyiflClRrOuNI4YU1zQRQBw==", "license": "MIT", "dependencies": { "@svgdotjs/svg.js": "3.2.0", @@ -4058,7 +4058,6 @@ "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4123,7 +4122,6 @@ "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.32.0", "@typescript-eslint/types": "8.32.0", @@ -4553,7 +4551,6 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4612,7 +4609,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5311,7 +5307,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -7122,7 +7117,6 @@ "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -7761,7 +7755,6 @@ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -8944,8 +8937,7 @@ "version": "0.24.8", "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/graphology-utils": { "version": "2.5.2", @@ -10512,7 +10504,6 @@ "integrity": "sha512-ek8NRg/OPvS9ISOJNWNAz5vZcpYacWNFDWNJjj5OXsc6YuKacfey6wF04cXz/tOJIVrZ2nGSkHpAY5qKtF6ISg==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "d": "^1.0.2", "duration": "^0.2.2", @@ -13880,7 +13871,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14335,7 +14325,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14665,7 +14654,6 @@ "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -14714,7 +14702,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -15060,7 +15047,6 @@ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -15292,7 +15278,6 @@ "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index e3cf5378..7db9b28b 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ }, "dependencies": { "@abstractplay/recranks": "latest", - "@abstractplay/renderer": "^1.0.0-ci-21051058249.0", + "@abstractplay/renderer": "^1.0.0-ci-22152124889.0", "@turf/boolean-contains": "^6.5.0", "@turf/boolean-intersects": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index c93559e0..3b8015b9 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IRenderOpts, IValidationResult } from "./_base"; import { APGamesInformation } from "../schemas/gameinfo"; import { APRenderRep, AreaKey, BoardBasic } from "@abstractplay/renderer/src/schemas/schema"; @@ -395,7 +394,7 @@ export class OonpiaGame extends GameBase { if (possibleMoves.length === 0) { return { valid: false, - message: i18next.t("apgames:validation.oonpia.INVALID_BOTH", {where: ms}), + message: i18next.t("apgames:validation.oonpia.INVALID_BOTH", {where: cell}), move: ms } } From 6c4adedd67cdc55afbcb8eae447fd933292464ac Mon Sep 17 00:00:00 2001 From: th555 Date: Sun, 22 Feb 2026 14:01:47 +0100 Subject: [PATCH 17/18] description --- src/games/oonpia.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index 3b8015b9..cf35e639 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -39,13 +39,13 @@ export class OonpiaGame extends GameBase { name: "Oonpia", uid: "oonpia", playercounts: [2], - version: "20251216", - dateAdded: "2025-12-16", + version: "20260222", + dateAdded: "2026-02-22", // i18next.t("apgames:descriptions.oonpia") description: "apgames:descriptions.oonpia", // i18next.t("apgames:notes.oonpia") notes: "apgames:notes.oonpia", - urls: ["https://boardgamegeek.com/thread/3251219/oonpia-new-4-colour-hexagonal-go-like"], + urls: ["https://github.com/th555/abstract_game_rules/blob/master/oonpia_rules.pdf"], people: [ { type: "designer", From 731185ef61bd296e2027d49437a66e65e01686ca Mon Sep 17 00:00:00 2001 From: th555 Date: Sun, 22 Feb 2026 20:11:02 +0100 Subject: [PATCH 18/18] complete=0 to make cycling through pieces possible --- locales/en/apgames.json | 2 +- src/games/oonpia.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 2ad0a19d..8a3797d6 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -138,7 +138,7 @@ "omny": "Generalized connection game where players try to split star cells into different regions so that no single region contains a majority of star cells.", "onager": "In Onager each player tries to reach the opponent's back rank. Onager is named after a Roman siege engine that is a type of catapult, as the way the pieces move resembles how projectiles are hurled forward with this device.", "onyx": "A connection game on a modified snub-square board with a capture rule.", - "oonpia": "A hexagonal go-like where pieces come in two types. Only pieces of opposite type are connected, and a 4-arc of pieces of the same type (regardless of colour) is forbidden.", + "oonpia": "A hexagonal go-like where pieces come in two types. Only pieces of opposite type are connected, and a 4-arc of pieces of the same type (regardless of colour) is forbidden. Either use the legend to select a piece, or click the same location multiple times to cycle through all possible pieces.", "orb": "Generatorb is 2-player game played on a standard chess board. Players start in opposite corners and attempt to reach their opponent's generator core or occupy the majority of cells on the front line. During play, you can stack up to three checkers in a space. Stacks of different heights behave differently, leading to engaging strategic options.", "ordo": "Ordo is a \"get to your opponent's home row\" game in which you must always keep your pieces connected. Pieces can move singly and also as a group in certain situations. You can also win by breaking up your opponent's group in such a way that they can't reconnect it.", "oust": "Oust is the classic \"exnihilation\" game where the game starts with an empty board and the goal is to eliminate all of your opponent's pieces. On your turn, you may make multiple capturing placements if available, but you must end it with a non-capturing placement. You must pass if you are not able to make any placements.", diff --git a/src/games/oonpia.ts b/src/games/oonpia.ts index cf35e639..989973d7 100644 --- a/src/games/oonpia.ts +++ b/src/games/oonpia.ts @@ -582,7 +582,7 @@ export class OonpiaGame extends GameBase { if (this.isValidCapture(move.cell, move.tile)) { return { valid: true, - complete: 1, + complete: 0, message: i18next.t("apgames:validation._general.VALID_MOVE") } } else { @@ -595,7 +595,7 @@ export class OonpiaGame extends GameBase { if (this.isValidPlace(move.cell, move.tile)) { return { valid: true, - complete: 1, + complete: 0, message: i18next.t("apgames:validation._general.VALID_MOVE") } } else {