From 5a20bf709d7b49dd54dae5f2e8f25a66aa5dbd4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= Date: Thu, 5 Feb 2026 18:01:43 +0000 Subject: [PATCH 1/4] Added Plurality --- locales/en/apgames.json | 17 ++ src/games/index.ts | 8 +- src/games/plurality.ts | 643 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 666 insertions(+), 2 deletions(-) create mode 100644 src/games/plurality.ts diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 49a573da..11202374 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -152,6 +152,7 @@ "pikemen": "A Looney pyramid game where the pyramids represents soldiers with long pikes. Pieces charge in the direction they're facing and can then change their orientation. Capture a certain number of enemy soldiers to win.\n\nWhile the game is technically playable by three and four players, this implementation only supports two players.", "pilastri": "Outlast your opponent in this game of blocking and moving, as pieces are forced to climb higher and higher until they run out of moves.", "pletore": "Territorial line-of-sight game. Intersections are controlled and pieces on them are flipped based on the number and direction of pieces that can see them. The winner is the player who occupies more of the board at the end.", + "plurality": "Territorial game where players place trominos to enclosure areas. The player with more stones on a given area's perimeter owns that area.", "pods": "Natal Seas - Pods is a territorial game where the players take on the metaphorical role of competing whales vying for space to protect their young. Played on a hexagonal board, it uses an innovative mechanism of placements and movements.", "pontedd": "Players build islands and attempt to connect as many of them as possible by bridge. Most points wins.", "prudh": "Prudh (\"prew\") is a game of shared pieces where one player owns the light squares and the other owns the dark. Manipulate stacks of pieces to earn points. Highest score at the end of the game wins.", @@ -1900,6 +1901,17 @@ "name": "Largest board (17x17)" } }, + "plurality": { + "#board": { + "name": "13x13 board" + }, + "size-15": { + "name": "15x15 board" + }, + "size-19": { + "name": "19x19 board" + } + }, "pontedd": { "size-8": { "name": "8x8 board" @@ -4912,6 +4924,11 @@ "KOMI_CHOICE": "Invoke pie to play first or accept playing as second player.", "OPPONENT_CONTROL": "You cannot place onto the empty intersection {{cell}}. It is controlled by the opponent." }, + "plurality": { + "INITIAL_INSTRUCTIONS": "Place a group of three orthogonal stones on empty cells. It is invalid to make a 2x2 shape of stones of any color configuration. First place two friendly pieces, then one opponent piece.", + "TABOO": "Place a tromino (orthogonal group of three stones), without making an occupied 2x2 area.", + "INCOMPLETE_TURN": "To complete the turn you need two friendly placements, then an opponent placement." + }, "pods": { "INITIAL_INSTRUCTIONS": "Place a piece onto an empty space where you have more neighboring pieces, or select a piece to move around other pieces without passing through spaces controlled by the opponent.", "FIRST_MOVE_INSTRUCTIONS": "Place a piece onto any empty space on the board.", diff --git a/src/games/index.ts b/src/games/index.ts index bb96f31c..30a88139 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -224,6 +224,7 @@ import { RincalaGame, IRincalaState } from "./rincala"; import { WaldMeisterGame, IWaldMeisterState } from "./waldmeister"; import { WunchunkGame, IWunchunkState } from "./wunchunk"; import { BambooGame, IBambooState } from "./bamboo"; +import { PluralityGame, IPluralityState } from "./plurality"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -451,6 +452,7 @@ export { WaldMeisterGame, IWaldMeisterState, WunchunkGame, IWunchunkState, BambooGame, IBambooState, + PluralityGame, IPluralityState, }; const games = new Map(); // Manually add each game to the following array [ @@ -564,7 +566,7 @@ const games = new Map { if (games.has(g.gameinfo.uid)) { throw new Error("Another game with the UID '" + g.gameinfo.uid + "' has already been used. Duplicates are not allowed."); @@ -1024,6 +1026,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new WunchunkGame(args[0], ...args.slice(1)); case "bamboo": return new BambooGame(...args); + case "plurality": + return new PluralityGame(...args); } return; } diff --git a/src/games/plurality.ts b/src/games/plurality.ts new file mode 100644 index 00000000..f606f144 --- /dev/null +++ b/src/games/plurality.ts @@ -0,0 +1,643 @@ +import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep, BoardBasic, MarkerDots, RowCol } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { reviver, UserFacingError, SquareOrthGraph } from "../common"; + +import { connectedComponents } from "graphology-components"; + +import i18next from "i18next"; + +export type playerid = 1 | 2 | 3; // 3 is for drawing neutral owned areas + +type Territory = { + cells: string[]; + owner: playerid|undefined; +}; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; + scores: [number, number]; +}; + +export interface IPluralityState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class PluralityGame extends GameBase { + + public static readonly gameinfo: APGamesInformation = { + name: "Plurality", + uid: "plurality", + playercounts: [2], + version: "20260202", + dateAdded: "2026-02-02", + // i18next.t("apgames:descriptions.plurality") + description: "apgames:descriptions.plurality", + urls: ["https://boardgamegeek.com/boardgame/462846/plurality"], + people: [ + { + type: "designer", + name: "João Pedro Neto", + }, + { + type: "coder", + name: "João Pedro Neto", + urls: [], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + categories: ["goal>area", "mechanic>place", "board>shape>rect"], + variants: [ + { uid: "#board", }, + { uid: "size-15", group: "board" }, + { uid: "size-19", group: "board" }, + ], + flags: ["scores", "experimental"] + }; + + public coords2algebraic(x: number, y: number): string { + return GameBase.coords2algebraic(x, y, this.boardSize); + } + public algebraic2coords(cell: string): [number, number] { + return GameBase.algebraic2coords(cell, this.boardSize); + } + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Map; + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public boardSize = 13; + public stack!: Array; + public results: Array = []; + public scores: [number, number] = [0, 0.5]; + + constructor(state?: IPluralityState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined) { + this.variants = [...variants]; + } + const board = new Map(); + const fresh: IMoveState = { + _version: PluralityGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + scores: [0, 0.5], + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as IPluralityState; + } + if (state.game !== PluralityGame.gameinfo.uid) { + throw new Error(`The Plurality 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): PluralityGame { + 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.boardSize = this.getBoardSize(); + this.scores = [...state.scores]; + 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 13; + } + + private neighbors(x: number, y: number): number[][] { + let result = []; + for (const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { + if (x+dx >= 0 && x+dx < this.boardSize && + y+dy >= 0 && y+dy < this.boardSize) { + const cell = this.coords2algebraic(x+dx, y+dy); + if (! this.board.has(cell)) { + result.push([x+dx, y+dy]); + } + } + } + return result; + } + + private isTaboo(x: number, y: number): boolean { + for (const [x1,y1] of [[x+1,y+1],[x-1,y+1],[x+1,y-1],[x-1,y-1]]) { + // (x1,y1) is an adjacent diagonal of cell (x,y) + if (x1 >= 0 && x1 < this.boardSize && y1 >= 0 && y1 < this.boardSize) { + let taboo = true; + for (const [x2,y2] of [[x,y1],[x1,y],[x1,y1]]) { + const cell = this.coords2algebraic(x2, y2); + // a 2x2 would appear if the other three coordinates are occupied + taboo = taboo && this.board.has(cell); + } + if (taboo) { return true; } + } + } + return false; + } + /** + * This should generate a full list of valid moves from the current game state. If it is not reasonable for your game to generate such a list, you can remove this function and add the `no-moves` flag to the game's metadata. If you *can* efficiently generate a move list, though, I highly recommend it. It's helpful to players, and it makes your life easier later. + */ + public moves(): string[] { + if (this.gameover) { return []; } + const moves: string[] = []; + let taboo; + + // can place on any empty space + for (let y = 0; y < this.boardSize; y++) { + for (let x = 0; x < this.boardSize; x++) { + const cell1 = this.coords2algebraic(x, y); + if (this.board.has(cell1) || this.isTaboo(x,y)) continue; + + for (const [x2,y2] of this.neighbors(x,y)) { + const cell2 = this.coords2algebraic(x2, y2); + if (this.board.has(cell2)) continue; + // check for 2nd stone taboo + this.board.set(cell1, this.currplayer); // temporary add cell1 to check taboo + taboo = this.isTaboo(x2,y2); + this.board.delete(cell1); // remove it! + if (taboo) { continue; } + // ------------------- end check + + for (const [x3,y3] of this.neighbors(x2,y2)) { + const cell3 = this.coords2algebraic(x3, y3); + if (cell1 === cell3 || this.board.has(cell3)) continue; + // check for 3rd stone taboo + this.board.set(cell1, this.currplayer); // temporary add cell1 + this.board.set(cell2, this.currplayer); // temporary add cell2 + let taboo = this.isTaboo(x3,y3); + this.board.delete(cell1); // remove it! + this.board.delete(cell2); // remove it! + if (taboo) { continue; } + // ------------------- end check + + // ok, no 2x2 was found, so add the two possible options + moves.push(cell1 + ',' + cell2 + ',' + cell3); // cell3 is enemy stone + moves.push(cell1 + ',' + cell3 + ',' + cell2); // cell2 is enemy stone + } + } + } + } + moves.push("pass"); + + return moves.sort((a,b) => a.localeCompare(b)) + } + + /** + * This is a helper function only needed for local testing, and only useful if you have a `moves()` function. + */ + public randomMove(): string { + const moves = this.moves(); + return moves[Math.floor(Math.random() * moves.length)]; + } + + /** + * This takes information about the move in progress and the click the user just made and needs to return an updated move string and some description of how valid and complete the move is. + * - `valid` must be either true or false. As long as the move is even partially valid, it should return true. False tells the front end that it's wholly and unsalvageably invalid. + * - `complete` has three states: -1, 0, and 1. -1 means the move is for absolutely sure NOT complete. More input is needed. 0 means the move *could* be complete and submitted now, but further moves are possible. And 1 means the move is absolutely complete and no further input should be expected. + * - `canrender` is for games where the moves consist of multiple steps and need to be rendered as you go. If `canrender` is true, then even if `complete` is -1, it will be send to the renderer for updating. + * - `message` is a translatable string explaining what the user should do next. + */ + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + let newmove = ""; + const cell = this.coords2algebraic(col, row); + if (move === "") { + newmove = cell; + } else { + newmove = move + "," + cell; + } + const result = this.validateMove(newmove) as IClickResult; + if (! result.valid) { + result.move = ""; + } else { + result.move = newmove; + } + return result; + } catch (e) { + return { + move, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", {move, row, col, piece, emessage: (e as Error).message}) + } + } + } + + /** + * This goes hand in hand with `handleClick()` and can be leveraged in other areas of the code as well. It accepts a move string and then returns a description of the move's condition. See description of `handleClick()` for details. + */ + public validateMove(m: string): IValidationResult { + const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + result.message = i18next.t("apgames:validation.plurality.INITIAL_INSTRUCTIONS") + return result; + } + + if (m === "pass") { + result.valid = true; + result.complete = 1; + return result; + } + + // a complete move corresponds to three placements, ie, three clicks + const moves = m.split(","); + + // is it a valid cell? + let currentMove; + try { + for (const p of moves) { + currentMove = p; + const [x, y] = this.algebraic2coords(p); + // `algebraic2coords` does not check if the cell is on the board. + if (x < 0 || x >= this.boardSize || y < 0 || y >= this.boardSize) { + throw new Error("Invalid cell"); + } + } + } catch { + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALIDCELL", { cell: currentMove }); + return result; + } + + // is cell empty? + let lastMove: string = moves[moves.length-1]; // get most recent placement + + let notEmpty; + if (this.board.has(lastMove)) { + notEmpty = lastMove; + } + if (notEmpty) { + result.valid = false; + result.message = i18next.t("apgames:validation._general.OCCUPIED", { where: notEmpty }); + return result; + } + + // Cell is empty, do we have three placements? + if (moves.length < 3) { + result.valid = true; + result.complete = -1; // need more placements! + result.message = i18next.t("apgames:validation.plurality.INCOMPLETE_TURN"); + return result; + } + + // Three stones were placed, must be a tromino and cannot make a 2x2 forbidden area + if (! this.moves().includes(m)) { + result.valid = false; + result.message = i18next.t("apgames:validation.plurality.TABOO", { where: notEmpty }); + return result; + } + + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + // --- These next methods are helpers to find territories and their eventual owners ---- // + + public getGraph(): SquareOrthGraph { // NB: just orthogonal connections + return new SquareOrthGraph(this.boardSize, this.boardSize); + } + + /** + * Get all moves() in format [ "a1,a2,a3", "a1,a2,b2", "a1,a3,a2", "a1,b1,b2"...] + * and returns a set with just the unique coordinates + */ + public getUniqueIds(data: string[]): Set { + // flatMap flattens the resulting arrays into one single array + const allIds = data.flatMap(item => item.split(',')); + return new Set(allIds); // remove duplicates + } + + /* + * An area is owned if it is not possible to play inside it. + * The set of possible moves are given by validMoves parameter + */ + public isAreaOwned(myArea: Array, validMoves: Set): boolean { + return myArea.every(id => !validMoves.has(id)); + } + + /** + * What pieces are orthogonally adjacent to a given area? + */ + public getAdjacentPieces(area: string[], pieces: string[]): string[] { + // convert area strings to numeric coordinates + const areaCoords = area.map(cell => this.algebraic2coords(cell)); + + return pieces.filter(pieceStr => { // Filter the pieces array + const piece = this.algebraic2coords(pieceStr); + + return areaCoords.some(square => { // check adjacency + const dx = Math.abs(piece[0] - square[0]); + const dy = Math.abs(piece[1] - square[1]); + return (dx == 1 && dy == 0) || (dx == 0 && dy == 1); + }); + }); + } + + /** + * Get all available territories (based in Asli) + * This is used in (1) computing scores, and (2) in the render process + */ + public getTerritories(): Territory[] { + const allValidMoves : Set = this.getUniqueIds([...this.moves()]); + const p1Pieces = [...this.board.entries()].filter(([,owner]) => owner === 1).map(pair => pair[0]); + const p2Pieces = [...this.board.entries()].filter(([,owner]) => owner === 2).map(pair => pair[0]); + const allPieces = [...p1Pieces, ...p2Pieces]; + + // compute empty areas + const gEmpties = this.getGraph(); + for (const node of gEmpties.graph.nodes()) { + if (allPieces.includes(node)) { // remove intersections/nodes with pieces + gEmpties.graph.dropNode(node); + } + } + const emptyAreas : Array> = connectedComponents(gEmpties.graph); + + const territories: Territory[] = []; + for(const area of emptyAreas) { + const isOwned = this.isAreaOwned(area, allValidMoves); + if (isOwned) { + let owner : playerid = 3; // default value: neutral aea + // find who owns it + const p1AdjacentCells = this.getAdjacentPieces(area, p1Pieces); + const p2AdjacentCells = this.getAdjacentPieces(area, p2Pieces); + if (p1AdjacentCells.length > p2AdjacentCells.length) { + owner = 1; + } + if (p1AdjacentCells.length < p2AdjacentCells.length) { + owner = 2; + } + territories.push({cells: area, owner}); + } + } + return territories; + } + + // ------------------------------------------------------------------------------------- // + + public move(m: string, {trusted = false} = {}): PluralityGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + + let valid_moves = this.moves(); + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + + if (! trusted) { + const result = this.validateMove(m); + if (! result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message) + } + if (! valid_moves.includes(m)) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) + } + } + + if (m.length === 0) { return this; } + + if (m === "pass") { + this.results.push({type: "pass"}); + } else { + const moves = m.split(","); + this.results.push({ type: "place", where: moves[0] }); + this.board.set(moves[0], this.currplayer); + + if (moves.length >= 2) { + this.results.push({ type: "place", where: moves[1] }); + this.board.set(moves[1], this.currplayer); + } + + if (moves.length === 3) { + this.results.push({ type: "place", where: moves[2] }); + this.board.set(moves[2], this.currplayer==1 ? 2 : 1); + } + } + + // compute scores by computing current owned territories + if ((m === "pass") || (m.split(",").length === 3)) { + const terr = this.getTerritories(); + this.scores = [ + terr.filter(t => t.owner === 1).reduce((prev, curr) => prev + curr.cells.length, 0.0), + terr.filter(t => t.owner === 2).reduce((prev, curr) => prev + curr.cells.length, 0.5), + ]; + } + + // update currplayer + this.lastmove = m; + let newplayer = (this.currplayer as number) + 1; + if (newplayer === 3) { + newplayer = 1; + } + this.currplayer = newplayer as playerid; + + this.checkEOG(); + this.saveState(); + return this; + } + + protected checkEOG(): PluralityGame { + this.gameover = this.lastmove === "pass" && this.stack[this.stack.length - 1].lastmove === "pass"; + + if (this.gameover) { + const p1Score = this.scores[0]; + const p2Score = this.scores[1]; + this.winner = p1Score > p2Score ? [1] : [2]; // draws are not possible + } + + if (this.gameover) { + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + return this; + } + + /** + * Anything up in your IPluralityState definition needs to be here. + */ + public state(): IPluralityState { + return { + game: PluralityGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + /** + * And same here for IMoveState. The base object uses these to save things. + * If you're new to TypeScript, you will want to familiarize yourself with the difference between reference types and value types. There's a reason you can't just say `board: this.board` in the below. You need to actually create a fresh map that duplicates `this.board`. + */ + public moveState(): IMoveState { + return { + _version: PluralityGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + scores: [...this.scores], + }; + } + + /** + * And this is how you turn a game state into something people can see and interact with. + * The system tries to abstract things as much as possible. You don't have to know anything about computer graphics. You just need to be able to get the rendering engine to do what you want. + * To learn that, you will want to visit and learn how the renderer works. Basically you need to choose a board, load your pieces, populate the board, and then annotate any recent moves. + * You will see a fair bit of `// @ts-ignore`. This is not good practice generally, but I have found them necessary here. The type system is very strict, and sometimes that gets in the way. As long as your render actually works in the playground, you're OK, regardless of what type errors are thrown here. + */ + public render(): APRenderRep { + // Build piece string + let pstr = ""; + for (let row = 0; row < this.boardSize; row++) { + if (pstr.length > 0) { + pstr += "\n"; + } + const pieces: string[] = []; + for (let col = 0; col < this.boardSize; col++) { + const cell = this.coords2algebraic(col, row); + if (this.board.has(cell)) { + const contents = this.board.get(cell)!; + if (contents === 1) { + pieces.push("A"); + } else { + pieces.push("B"); + } + } else { + pieces.push("-"); + } + } + pstr += pieces.join(""); + } + pstr = pstr.replace(new RegExp(`-{${this.boardSize}}`, "g"), "_"); + + // Build rep + const rep: APRenderRep = { + options: ["hide-star-points"], + board: { + style: "vertex", + width: this.boardSize, + height: this.boardSize, + }, + legend: { + A: [{ name: "piece", colour: 1 }], + B: [{ name: "piece", colour: 2 }], + }, + pieces: pstr + }; + + // add territory dots + const territories = this.getTerritories(); + let markers: Array | undefined = [] + for (const t of territories) { + if (t.owner !== undefined) { + const points = t.cells.map(c => this.algebraic2coords(c)); + markers.push({type: "dots", colour: t.owner, points: points.map(p => { return {col: p[0], row: p[1]}; }) as [RowCol, ...RowCol[]]}); + } + } + if (markers.length === 0) { + markers = undefined; + } + if (markers !== undefined) { + (rep.board as BoardBasic).markers = markers; + } + + // Add annotations + if (this.stack[this.stack.length - 1]._results.length > 0) { + // @ts-ignore + rep.annotations = []; + for (const move of this.stack[this.stack.length - 1]._results) { + if (move.type === "move") { + const [fromX, fromY] = this.algebraic2coords(move.from); + const [toX, toY] = this.algebraic2coords(move.to); + rep.annotations.push({type: "move", targets: [{row: fromY, col: fromX}, {row: toY, col: toX}]}); + } else if (move.type === "place") { + const [x, y] = this.algebraic2coords(move.where!); + rep.annotations.push({type: "enter", targets: [{row: y, col: x}]}); + } + } + } + + return rep; + } + + /** + * This function is only for the local playground. + */ + public status(): string { + let status = super.status(); + if (this.variants !== undefined) { + status += "**Variants**: " + this.variants.join(", ") + "\n\n"; + } + return status; + } + + /** + * This is for rendering each move in the front end's chat log. + * For simple games, you can start by deleting this and going with the defaults. + * And then, if you need something special, it might be simpler just to ask for direction in the Discord. But basically you can customize the chat message for your specific game. + */ + /* + public chat(node: string[], player: string, results: APMoveResult[], r: APMoveResult): boolean { + let resolved = false; + switch (r.type) { + case "place": + node.push(i18next.t("apresults:PLACE.nowhat", {player, where: r.where})); + resolved = true; + break; + case "move": + resolved = true; + break; + } + return resolved; + }*/ + + /** + * Just leave this. You very, very rarely need to do anything here. + */ + public clone(): PluralityGame { + return new PluralityGame(this.serialize()); + } +} From dd36ccabede700cd80b66f3b8b2cf5e29c4aaa55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= Date: Thu, 5 Feb 2026 18:45:43 +0000 Subject: [PATCH 2/4] Update plurality.ts --- src/games/plurality.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/games/plurality.ts b/src/games/plurality.ts index f606f144..4fb1fdcc 100644 --- a/src/games/plurality.ts +++ b/src/games/plurality.ts @@ -266,6 +266,7 @@ export class PluralityGame extends GameBase { if (m.length === 0) { result.valid = true; result.complete = -1; + result.canrender = true; result.message = i18next.t("apgames:validation.plurality.INITIAL_INSTRUCTIONS") return result; } @@ -273,6 +274,7 @@ export class PluralityGame extends GameBase { if (m === "pass") { result.valid = true; result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); return result; } @@ -313,6 +315,7 @@ export class PluralityGame extends GameBase { if (moves.length < 3) { result.valid = true; result.complete = -1; // need more placements! + result.canrender = true; result.message = i18next.t("apgames:validation.plurality.INCOMPLETE_TURN"); return result; } @@ -326,6 +329,7 @@ export class PluralityGame extends GameBase { result.valid = true; result.complete = 1; + result.canrender = true; result.message = i18next.t("apgames:validation._general.VALID_MOVE"); return result; } @@ -395,7 +399,7 @@ export class PluralityGame extends GameBase { for(const area of emptyAreas) { const isOwned = this.isAreaOwned(area, allValidMoves); if (isOwned) { - let owner : playerid = 3; // default value: neutral aea + let owner : playerid = 3; // default value: neutral area // find who owns it const p1AdjacentCells = this.getAdjacentPieces(area, p1Pieces); const p2AdjacentCells = this.getAdjacentPieces(area, p2Pieces); From 78e4a3546c976e71d64cf3977a2336966a7aa9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= Date: Thu, 5 Feb 2026 22:04:59 +0000 Subject: [PATCH 3/4] Aaron's feedback adjustments --- locales/en/apgames.json | 2 +- src/games/plurality.ts | 55 +++++++++++++++++++---------------------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 11202374..8a5b823d 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -4926,7 +4926,7 @@ }, "plurality": { "INITIAL_INSTRUCTIONS": "Place a group of three orthogonal stones on empty cells. It is invalid to make a 2x2 shape of stones of any color configuration. First place two friendly pieces, then one opponent piece.", - "TABOO": "Place a tromino (orthogonal group of three stones), without making an occupied 2x2 area.", + "TABOO": "Place a tromino (orthogonal group of three stones), without making an occupied 2x2 area. The next click will start a new tromino.", "INCOMPLETE_TURN": "To complete the turn you need two friendly placements, then an opponent placement." }, "pods": { diff --git a/src/games/plurality.ts b/src/games/plurality.ts index 4fb1fdcc..bb2be151 100644 --- a/src/games/plurality.ts +++ b/src/games/plurality.ts @@ -238,8 +238,15 @@ export class PluralityGame extends GameBase { const cell = this.coords2algebraic(col, row); if (move === "") { newmove = cell; - } else { - newmove = move + "," + cell; + } else { + let cells = move.split(","); + let idx = cells.indexOf(cell); // check if some piece was clicked twice + if (idx === -1) { + newmove = move + "," + cell; // if not, just add move + } else { + cells.splice(idx); // otherwise, remove/unplace it + newmove = cells.join(","); + } } const result = this.validateMove(newmove) as IClickResult; if (! result.valid) { @@ -280,7 +287,7 @@ export class PluralityGame extends GameBase { // a complete move corresponds to three placements, ie, three clicks const moves = m.split(","); - + // is it a valid cell? let currentMove; try { @@ -297,6 +304,15 @@ export class PluralityGame extends GameBase { result.message = i18next.t("apgames:validation._general.INVALIDCELL", { cell: currentMove }); return result; } + + // get all valid complete moves (so each move will be like "a1,b1,c1") + const allMoves = this.moves(); + // does any of these moves make a taboo? A taboo will not be a prefix of any legal move + if (! allMoves.some(legalMove => legalMove.startsWith(m))) { + result.valid = false; + result.message = i18next.t("apgames:validation.plurality.TABOO", { cell: currentMove }); + return result; + } // is cell empty? let lastMove: string = moves[moves.length-1]; // get most recent placement @@ -321,7 +337,7 @@ export class PluralityGame extends GameBase { } // Three stones were placed, must be a tromino and cannot make a 2x2 forbidden area - if (! this.moves().includes(m)) { + if (! allMoves.includes(m)) { result.valid = false; result.message = i18next.t("apgames:validation.plurality.TABOO", { where: notEmpty }); return result; @@ -417,7 +433,7 @@ export class PluralityGame extends GameBase { // ------------------------------------------------------------------------------------- // - public move(m: string, {trusted = false} = {}): PluralityGame { + public move(m: string, {partial = false, trusted = false} = {}): PluralityGame { if (this.gameover) { throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); } @@ -431,7 +447,7 @@ export class PluralityGame extends GameBase { if (! result.valid) { throw new UserFacingError("VALIDATION_GENERAL", result.message) } - if (! valid_moves.includes(m)) { + if (!partial && ! valid_moves.includes(m)) { throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) } } @@ -456,6 +472,8 @@ export class PluralityGame extends GameBase { } } + if (partial) { return this; } + // compute scores by computing current owned territories if ((m === "pass") || (m.split(",").length === 3)) { const terr = this.getTerritories(); @@ -464,7 +482,7 @@ export class PluralityGame extends GameBase { terr.filter(t => t.owner === 2).reduce((prev, curr) => prev + curr.cells.length, 0.5), ]; } - + // update currplayer this.lastmove = m; let newplayer = (this.currplayer as number) + 1; @@ -618,29 +636,6 @@ export class PluralityGame extends GameBase { return status; } - /** - * This is for rendering each move in the front end's chat log. - * For simple games, you can start by deleting this and going with the defaults. - * And then, if you need something special, it might be simpler just to ask for direction in the Discord. But basically you can customize the chat message for your specific game. - */ - /* - public chat(node: string[], player: string, results: APMoveResult[], r: APMoveResult): boolean { - let resolved = false; - switch (r.type) { - case "place": - node.push(i18next.t("apresults:PLACE.nowhat", {player, where: r.where})); - resolved = true; - break; - case "move": - resolved = true; - break; - } - return resolved; - }*/ - - /** - * Just leave this. You very, very rarely need to do anything here. - */ public clone(): PluralityGame { return new PluralityGame(this.serialize()); } From 2d71db9b57dd37eb4c84facf7c8c21f524af50b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= Date: Thu, 5 Feb 2026 22:11:44 +0000 Subject: [PATCH 4/4] Added a missing valid move combination --- src/games/plurality.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/games/plurality.ts b/src/games/plurality.ts index bb2be151..83a80be7 100644 --- a/src/games/plurality.ts +++ b/src/games/plurality.ts @@ -206,6 +206,7 @@ export class PluralityGame extends GameBase { // ------------------- end check // ok, no 2x2 was found, so add the two possible options + moves.push(cell2 + ',' + cell1 + ',' + cell3); // cell3 is enemy stone moves.push(cell1 + ',' + cell2 + ',' + cell3); // cell3 is enemy stone moves.push(cell1 + ',' + cell3 + ',' + cell2); // cell2 is enemy stone }