From 31850456c790c188c50a18641334c4999edfd109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= <886455+jpneto@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:15:19 +0100 Subject: [PATCH 1/3] Update printing dots for VirusWar --- locales/en/apgames.json | 2 +- src/games/viruswar.ts | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 03d73563..c893c379 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -6808,7 +6808,7 @@ }, "viruswar": { "INITIAL_INSTRUCTIONS": "Place {{count}} pieces, each must be adjacent to the group of friendly pieces including your home-base. Pieces can be active viruses (circles), or defensive walls (squares). Either place a virus on an empty cell, or replace an enemy virus with a friendly defensive wall.", - "CANNOT_GROW": "Square {{where}} cannot receive a new virus/wall! It must be adjacent to the home-base group." + "CANNOT_GROW": "Square {{where}} cannot receive a new virus/wall! It must be placed on an empty cell, or on an active virus, adjacent to the home-base group." }, "volcano": { "BAD_FROM": "{{from}} doesn't look like a position on the board, or a piece in your stash.", diff --git a/src/games/viruswar.ts b/src/games/viruswar.ts index 80c9be60..d86ecd9d 100644 --- a/src/games/viruswar.ts +++ b/src/games/viruswar.ts @@ -120,6 +120,7 @@ export class VirusWarGame extends GameBase { this.boardSize = this.getBoardSize(); this.numMoves = this.getMoveSize(); this.results = [...state._results]; + this.dots = this.getAdjacentMoves(this.currplayer, this.board); // show dots before the player acts return this; } @@ -282,11 +283,7 @@ export class VirusWarGame extends GameBase { throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); } - if (m.length === 0) { // show all available moves even before selecting anything - this.results = []; - this.dots = this.getAdjacentMoves(this.currplayer, this.board); - return this; - } + if (m.length === 0) { return this; } m = m.toLowerCase(); m = m.replace(/\s+/g, ""); @@ -306,8 +303,7 @@ export class VirusWarGame extends GameBase { this.results = [{ type: "place", where: m }]; if (partial) { // if partial, populate dots - const validMoves = this.getAdjacentMoves(this.currplayer, this.board); - this.dots.push(...validMoves); + this.dots = this.getAdjacentMoves(this.currplayer, this.board); return this; } else { this.dots = []; From 389394df8e8ac28b72f273d504f9218e289fb05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= <886455+jpneto@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:34:08 +0100 Subject: [PATCH 2/3] Added Compart --- locales/en/apgames.json | 32 +++ src/games/compart.ts | 421 ++++++++++++++++++++++++++++++++++++++++ src/games/index.ts | 9 +- src/games/linage.ts | 9 +- 4 files changed, 468 insertions(+), 3 deletions(-) create mode 100644 src/games/compart.ts diff --git a/locales/en/apgames.json b/locales/en/apgames.json index c893c379..ce5063a4 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -55,6 +55,7 @@ "cifra": "A breakthrough-style game where the board configuration changes from game to game.", "clearcut": "Connect opposite sides of the board. Lines of pieces may cross in some circumstances, and this results in the removal of some opposing pieces.", "clusterfuss": "Continually capture pieces with the aim of detaching (and thereby eliminating) groups of opposing pieces. Be the last man standing!", + "compart": "Play on each viable area, but be the one with less pieces at the end.", "complica": "A simple connect-4 game played on a narrow field where the columns never fill up.", "conect": "A connection game played on the curved surface of a cone. The goal is to create a closed loop from the player's edge to the centre, or to connect a path to the centre. The game can be played on a projection, which is isomorphic to the usual Hex rhombus with some cells overlapping or removed.", "conhex": "In ConHex, the winner is the first player to connect their assigned sides of a square board. The board is a pattern of non-regular hexagons with a few non-hexagonal polygons. Players alternate turns placing pieces of their own color on a vertex, and a player can claim a space after placing pieces on at least half the vertices of that space.", @@ -986,6 +987,26 @@ "name": "Larger board (10x10)" } }, + "compart": { + "size-7": { + "name": "7x7 board" + }, + "#board": { + "name": "9x9 board" + }, + "size-11": { + "name": "11x11 board" + }, + "size-13": { + "name": "13x13 board" + }, + "size-15": { + "name": "15x15 board" + }, + "size-19": { + "name": "19x19 board" + } + }, "conect": { "#board": { "name": "Size-11 board" @@ -3621,6 +3642,12 @@ "name": "Hide markers" } }, + "compart": { + "show-viable-areas": { + "description": "Highlight viable areas where placements are required.", + "name": "Show viable areas" + } + }, "conect": { "display-hex": { "description": "Display game on the rhombus board.", @@ -4783,6 +4810,11 @@ "PARTIAL": "Select an orthogonally adjacent piece to capture (yours or your opponent's).", "SINGLE_GROUP": "After your capture, there must be only one orthogonally connected group that contains your own checkers." }, + "compart": { + "INSTRUCTIONS": "Place {{count}} piece(s), one on each friendly viable area (connected groups of friendly pieces and empty cells).", + "OCCUPIED": "Placements only on empty cells!", + "VIABLE_AREA_REPEATED": "Viable areas can only have one placement!" + }, "complica": { "INITIAL_INSTRUCTIONS": "Select the column into which to drop your stone." }, diff --git a/src/games/compart.ts b/src/games/compart.ts new file mode 100644 index 00000000..d51e3167 --- /dev/null +++ b/src/games/compart.ts @@ -0,0 +1,421 @@ +import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult, IScores, IRenderOpts } 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; + +type Territory = { + cells: string[]; + owner: playerid | undefined; +}; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; +}; + +export interface ICompartState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class CompartGame extends GameBase { + + public static readonly gameinfo: APGamesInformation = { + name: "Compart", + uid: "compart", + playercounts: [2], + version: "20260612", + dateAdded: "2026-06-12", + // i18next.t("apgames:descriptions.compart") + description: "apgames:descriptions.compart", + urls: [ + "https://boardgamegeek.com/boardgame/385587/compart" + ], + people: [ + { + type: "designer", + name: "Luis Bolaños Mures", + urls: ["https://boardgamegeek.com/boardgamedesigner/47001/luis-bolanos-mures"], + apid: "6b518a3f-7f63-47b8-b92b-a04792fba8e7", + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + categories: ["goal>area", "mechanic>place", "board>shape>rect", "board>connect>rect"], + variants: [ + { uid: "size-7", group: "board" }, + { uid: "#board", }, // 9x9 + { uid: "size-11", group: "board" }, + { uid: "size-13", group: "board" }, + { uid: "size-15", group: "board" }, + { uid: "size-19", group: "board" }, + ], + flags: ["no-moves", "pie", "scores", "experimental"], + displays: [{uid: "show-viable-areas"}], + }; + + 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 = 9; + public stack!: Array; + public results: Array = []; + + constructor(state?: ICompartState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined) { + this.variants = [...variants]; + } + const board = new Map(); + const fresh: IMoveState = { + _version: CompartGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as ICompartState; + } + if (state.game !== CompartGame.gameinfo.uid) { + throw new Error(`The Compart 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): CompartGame { + 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.results = [...state._results]; + 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 9; + } + + private getGraph(): SquareOrthGraph { // just orthogonal connections + return new SquareOrthGraph(this.boardSize, this.boardSize); + } + + // get all viable areas of `player` + private getTerritories(player?: playerid): Territory[] { + player ??= this.currplayer; + const oppPieces = [...this.board.entries()].filter(p => p[1] !== player).map(p => p[0]); + + // compute viable areas + const g = this.getGraph(); + for (const node of g.graph.nodes()) { + if (oppPieces.includes(node)) { // remove intersections with opponent pieces + g.graph.dropNode(node); + } + } + const viableAreas : Array> = connectedComponents(g.graph); + + const territories: Territory[] = []; + for(const area of viableAreas) { + // viable areas must have at least one empty cell + if ( [...area].some(c => !this.board.has(c)) ) { + territories.push({cells: area, owner: player}); + } + } + return territories; + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const cell = this.coords2algebraic(col, row); + let newmove = ""; + + if ( move === "" ) { + newmove = cell; + } else { + const moves = move.split(","); + if ( moves.includes(cell) ) { // check if the cell already was clicked + newmove = moves.filter(c => c !== cell).join(","); // if clicked, undo it + } else { + newmove = `${move},${cell}`; // otherwise, append new cell + } + } + + const result = this.validateMove(newmove) as IClickResult; + result.move = result.valid ? newmove : move; + return result; + } catch (e) { + return { + move, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", {move, row, col, piece, emessage: (e as Error).message}) + } + } + } + + public validateMove(m: string): IValidationResult { + const result: IValidationResult = {valid: false, + message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + const viableAreas = this.getTerritories(); + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + result.message = i18next.t("apgames:validation.compart.INSTRUCTIONS", {count: viableAreas.length}) + return result; + } + + const moves = m.split(','); + + try { // check if cells are valid + for (const cell of moves) { this.algebraic2coords(cell); } + } catch { + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALID_MOVE", {move: m}); + return result; + } + + const seenAreas: number[] = []; // keeps indices of areas already with placement + + for (const cell of moves) { + if ( this.board.has(cell) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.compart.OCCUPIED"); + return result; + } + + // check which viable area the cell belongs to + let idx = 0; + for (const area of viableAreas) { + if ( area.cells.includes(cell) ) { + if ( seenAreas.includes(idx) ) { // a viable area can only have one placement + result.valid = false; + result.message = i18next.t("apgames:validation.compart.VIABLE_AREA_REPEATED"); + return result; + } else { + seenAreas.push(idx); + } + } + idx += 1; + } + } + + result.valid = true; + result.complete = seenAreas.length < viableAreas.length ? -1 : 1; + result.canrender = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + public move(m: string, {partial = false, trusted = false} = {}): CompartGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + + if (m.length === 0) { return this; } + + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + this.results = []; + + if (! trusted) { + const result = this.validateMove(m); + if (! result.valid) { throw new UserFacingError("VALIDATION_GENERAL", result.message) } + } + + for (const cell of m.split(',')) { + this.board.set(cell, this.currplayer); + this.results.push({ type: "place", where: cell }); + } + + if (partial) { return this; } + + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + this.checkEOG(); + this.saveState(); + return this; + } + + protected checkEOG(): CompartGame { + const numP1Pieces = this.getPlayerScore(1); + const numP2Pieces = this.getPlayerScore(2); + + this.gameover = numP1Pieces + numP2Pieces === this.boardSize * this.boardSize; + + if (this.gameover) { + if ( numP1Pieces === numP2Pieces ) { + // if there is a tie, whoever placed the last stone wins + this.winner = [this.currplayer % 2 + 1 as playerid]; + } else { + this.winner = numP1Pieces < numP2Pieces ? [1] : [2]; + } + this.results.push( {type: "eog"}, + {type: "winners", players: [...this.winner]} ); + } + return this; + } + + public render(opts?: IRenderOpts): APRenderRep { + let altDisplay: string | undefined; + if (opts !== undefined) { + altDisplay = opts.altDisplay; + } + let highlightAreas = false; + if (altDisplay !== undefined) { + if (altDisplay === "show-viable-areas") { + highlightAreas = true; + } + } + + // 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 + if (highlightAreas && this.stack.length > 2) { + const palette = ["#228B22", "#FF8C00", "#8A2BE2", "#8B4513", "#FFD700", "#E1A95F", "#FFA07A", "#7FFF00"] + const territories = this.getTerritories().sort((a, b) => b.cells.length - a.cells.length); + const markers: Array = [] + let colorIdx = 0 + for (const t of territories) { + const points = t.cells.map(c => this.algebraic2coords(c)); + markers.push({type: "dots", + colour: palette[colorIdx], + points: points.map(p => { return {col: p[0], row: p[1]}; }) as [RowCol, ...RowCol[]]}); + colorIdx += 1; + } + if (markers.length > 0) { + (rep.board as BoardBasic).markers = markers; + } + } + + // Add annotations + if (this.results.length > 0) { + rep.annotations = []; + for (const move of this.results) { + if (move.type === "place") { + const [x, y] = this.algebraic2coords(move.where!); + rep.annotations.push({type: "enter", targets: [{row: y, col: x}]}); + } + } + } + + return rep; + } + + public getPlayerScore(player: number): number { + return [...this.board.entries()].filter(([,owner]) => owner === player).length; + } + + public sidebarScores(): IScores[] { + return [ { name: i18next.t("apgames:status.SCORES"), + scores: [this.getPlayerScore(1), + this.getPlayerScore(2)] } ]; + } + + public state(): ICompartState { + return { + game: CompartGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + public moveState(): IMoveState { + return { + _version: CompartGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + }; + } + + public clone(): CompartGame { + return new CompartGame(this.serialize()); + } +} diff --git a/src/games/index.ts b/src/games/index.ts index af68dad7..a813789e 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -262,6 +262,7 @@ import { TricouleurGame, ITricouleurState } from "./tricouleur"; import { PositGame, IPositState } from "./posit"; import { VirusWarGame, IVirusWarState } from "./viruswar"; import { FormsGame, IFormsState } from "./forms"; +import { CompartGame, ICompartState } from "./compart"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -527,6 +528,7 @@ export { PositGame, IPositState, VirusWarGame, IVirusWarState, FormsGame, IFormsState, + CompartGame, ICompartState, }; const games = new Map(); // Manually add each game to the following array [ @@ -657,7 +660,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."); @@ -1193,6 +1196,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new VirusWarGame(args[0], ...args.slice(1)); case "forms": return new FormsGame(...args); + case "compart": + return new CompartGame(...args); } return; } diff --git a/src/games/linage.ts b/src/games/linage.ts index 2aae6150..83228fe3 100644 --- a/src/games/linage.ts +++ b/src/games/linage.ts @@ -418,7 +418,14 @@ export class LinageGame extends GameBase { this.stack[this.stack.length - 1].lastmove === "pass"); if (this.gameover) { - this.winner = this.getPlayerScore(1) > this.getPlayerScore(2) ? [1] : [2]; // draws not possible + const score1 = this.getPlayerScore(1); + const score2 = this.getPlayerScore(2); + + if ( score1 === score2 ) { + this.winner = [1, 2]; // only possible if neither player takes the button (!) + } else { + this.winner = score1 > score2 ? [1] : [2]; + } this.results.push( {type: "eog"}, {type: "winners", players: [...this.winner]} From 3e80a742f5ca22e4bbda454fdf37545d4fed88fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= <886455+jpneto@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:22:04 +0100 Subject: [PATCH 3/3] Update compart.ts --- src/games/compart.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/games/compart.ts b/src/games/compart.ts index d51e3167..3d74e1d3 100644 --- a/src/games/compart.ts +++ b/src/games/compart.ts @@ -6,6 +6,17 @@ import { reviver, UserFacingError, SquareOrthGraph } from "../common"; import { connectedComponents } from "graphology-components"; import i18next from "i18next"; +const PALETTE = [ + "#228B22", // Forest Green + "#FF8C00", // Dark Orange + "#8A2BE2", // Blue Violet + "#FFD700", // Gold + "#8B4513", // Saddle Brown + "#FFC0CB", // Pink + "#FFA07A", // Light Salmon + "#BF00FF", // Electric Purple + ] + export type playerid = 1 | 2; type Territory = { @@ -51,7 +62,7 @@ export class CompartGame extends GameBase { apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", }, ], - categories: ["goal>area", "mechanic>place", "board>shape>rect", "board>connect>rect"], + categories: ["goal>score>eog", "mechanic>place", "board>shape>rect", "board>connect>rect"], variants: [ { uid: "size-7", group: "board" }, { uid: "#board", }, // 9x9 @@ -353,16 +364,15 @@ export class CompartGame extends GameBase { // add territory dots if (highlightAreas && this.stack.length > 2) { - const palette = ["#228B22", "#FF8C00", "#8A2BE2", "#8B4513", "#FFD700", "#E1A95F", "#FFA07A", "#7FFF00"] const territories = this.getTerritories().sort((a, b) => b.cells.length - a.cells.length); const markers: Array = [] let colorIdx = 0 for (const t of territories) { const points = t.cells.map(c => this.algebraic2coords(c)); markers.push({type: "dots", - colour: palette[colorIdx], + colour: PALETTE[colorIdx], points: points.map(p => { return {col: p[0], row: p[1]}; }) as [RowCol, ...RowCol[]]}); - colorIdx += 1; + colorIdx = (colorIdx + 1) % PALETTE.length; } if (markers.length > 0) { (rep.board as BoardBasic).markers = markers;