diff --git a/locales/en/apgames.json b/locales/en/apgames.json index c44294d5..2cd139d0 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -182,6 +182,7 @@ "spook": "Guide Spooky the ghost around the pyramid and try to be the first player to have all balls of your colour removed. The game takes place in two phases: the setup phase where the pyramid is built, and the getaway phase, where Spooky starts at the top of the pyramid and starts eliminating balls.", "spree": "An impartial n-in-a-row game where players take turns handing a ball to their opponent to place. The goal is to try to make a full line of one of two colours (red and blue by default). There is a third wild neutral colour (green by default) that can play the role of either colour.", "squaredance": "A game where you move groups of pieces by rotation, trying to force your opponent into a position where they cannot move.", + "squava": "Players try to win by making 4 in-a-row, but lose when doing just a 3 in-a-row.", "stairs": "Stack higher than your opponent in this game of one-space, one-level jumps where you also have to move your lowest pieces first. To take the lead and win, you must surpass your opponent's tallest stack height or, failing that, their number of stacks at the tallest height.", "stawvs": "A set collection game for 2-4 players that's similar to Amazons, with old-style Volcano scoring. On a checkerboard randomly filled with individual pyramids, player pieces can move and capture pyramids in a straight line (orthogonally or diagonally), if and only if all intervening spaces and the target space contain pyramids but not pieces.", "stibro": "Win by forming a loop anywhere on the board, while adhering to the placement restriction that ensures loop-forming is always possible.", @@ -5307,6 +5308,9 @@ "PARTIAL_DESTINATION": "Select the point where the end stone will end up. You may not cover friendly pieces, and any covered enemy pieces will be captured.", "PARTIAL_ENDSTONE": "Select the end stone. This is the stone that will move the furthest." }, + "squava": { + "INITIAL_INSTRUCTIONS": "Select an empty cell to place a stone." + }, "stairs": { "NORMAL_MOVE": "Choose a piece to move.", "INVALID_MOVE": "You must move a piece to a neighboring stack of the same height. You must move one of your lowest movable pieces.", diff --git a/src/games/index.ts b/src/games/index.ts index bb96f31c..1e633969 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -181,6 +181,7 @@ import { ChurnGame, IChurnState } from "./churn"; import { PenguinGame, IPenguinState } from "./penguin"; import { OwlmanGame, IOwlmanState } from "./owlman"; import { SquaredanceGame, ISquaredanceState } from "./squaredance"; +import { SquavaGame, ISquavaState } from "./squava"; import { MegGame, IMegState } from "./meg"; import { YonmoqueGame, IYonmoqueState } from "./yonmoque"; import { ChameleonGame, IChameleonState } from "./chameleon"; @@ -408,6 +409,7 @@ export { PenguinGame, IPenguinState, OwlmanGame, IOwlmanState, SquaredanceGame, ISquaredanceState, + SquavaGame, ISquavaState, MegGame, IMegState, YonmoqueGame, IYonmoqueState, ChameleonGame, IChameleonState, @@ -529,7 +531,7 @@ 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."); @@ -938,6 +940,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new OwlmanGame(...args); case "squaredance": return new SquaredanceGame(...args); + case "squava": + return new SquavaGame(...args); case "meg": return new MegGame(...args); case "yonmoque": diff --git a/src/games/squava.ts b/src/games/squava.ts new file mode 100644 index 00000000..74f35f9c --- /dev/null +++ b/src/games/squava.ts @@ -0,0 +1,399 @@ +import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { RectGrid, reviver, UserFacingError } from "../common"; +import { InARowBase } from "./in_a_row/InARowBase"; + +import i18next from "i18next"; + +export type playerid = 1|2; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; + winningLines: string[][]; +}; + +export interface ISquavaState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class SquavaGame extends InARowBase { + + public static readonly gameinfo: APGamesInformation = { + name: "Squava", + uid: "squava", + playercounts: [2], + version: "20260129", + dateAdded: "2026-01-31", + description: "apgames:descriptions.squava", + urls: ["https://boardgamegeek.com/boardgame/112745/squava"], + people: [ + { + type: "designer", + name: "Néstor Romeral Andrés", + }, + { + type: "coder", + name: "João Pedro Neto", + urls: [], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + categories: ["goal>align", "mechanic>place", "board>shape>rect"], + flags: ["pie","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 = 5; + public stack!: Array; + public results: Array = []; + + public winningLines: string[][] = []; + public winningLineLength = 4; + + public defaultBoardSize = 5; + public swapped = false; + + constructor(state?: ISquavaState | string) { + super(); + if (state === undefined) { + const board = new Map(); + const fresh: IMoveState = { + _version: SquavaGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + winningLines: [], + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as ISquavaState; + } + if (state.game !== SquavaGame.gameinfo.uid) { + throw new Error(`The Squava 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): SquavaGame { + 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.winningLines = state.winningLines.map(a => [...a]); + this.lastmove = state.lastmove; + this.boardSize = this.getBoardSize(); + return this; + } + + public moves(): string[] { + if (this.gameover) { return []; } + const moves: string[] = []; + + // can place on any empty space + for (let y = 0; y < this.boardSize; y++) { + for (let x = 0; x < this.boardSize; x++) { + const cell = this.coords2algebraic(x, y); + if (! this.board.has(cell)) { + moves.push(cell); + } + } + } + + return moves.sort((a,b) => a.localeCompare(b)) + } + + public randomMove(): string { + const moves = this.moves(); + return moves[Math.floor(Math.random() * moves.length)]; + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const cell = this.coords2algebraic(col, row); + const newmove = cell; + const result = this.validateMove(newmove) as IClickResult; + if (! result.valid) { + result.move = ""; + } else { + result.move = newmove; + } + return result; + } catch (e) { + return { + move, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", {move, row, col, piece, emessage: (e as Error).message}) + } + } + } + + public validateMove(m: string): IValidationResult { + const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + result.message = i18next.t("apgames:validation.squava.INITIAL_INSTRUCTIONS") + return result; + } + + // Is cell empty? + let notEmpty; + if (this.board.has(m)) { + notEmpty = m; + } + if (notEmpty) { + result.valid = false; + result.message = i18next.t("apgames:validation._general.OCCUPIED", { where: notEmpty }); + return result; + } + + // Cell is empty, so move looks good + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE", { where: m }); + return result; + } + + public move(m: string, {trusted = false} = {}): SquavaGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + + if (! trusted) { + const result = this.validateMove(m); + if (! result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message) + } + if (! this.moves().includes(m)) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) + } + } + + if (m.length === 0) { return this; } + + this.results.push({ type: "place", where: m }); + this.board.set(m, this.currplayer); + + // update currplayer + this.lastmove = m; + let newplayer = (this.currplayer as number) + 1; + if (newplayer > this.numplayers) { + newplayer = 1; + } + this.currplayer = newplayer as playerid; + + this.checkEOG(); + this.saveState(); + return this; + } + + /** + * Helper function: checks if a given player has a in-a-row + */ + private hasRow(player: playerid, size: number): boolean { + const grid = new RectGrid(this.boardSize, this.boardSize); + for (let y = 0; y < this.boardSize; y++) { + for (let x = 0; x < this.boardSize; x++) { + for (const dir of ["W","SW","S","SE","E"] as const) { + const ray = [[x,y] as [number,number], ...grid.ray(x, y, dir)].map(node => this.coords2algebraic(...node)); + if (ray.length >= size) { + let found = true; + for (const cell of ray.slice(0,size)) { + if ( (! this.board.has(cell)) || (this.board.get(cell)! !== player) ) { + found = false; + break; + } + } + if (found) { + return true; + } + } + } + } + } + return false; + } + + private hasThrees(player: playerid): boolean { + return this.hasRow(player, 3); + } + + private hasFours(player: playerid): boolean { + return this.hasRow(player, 4); + } + + protected checkEOG(): SquavaGame { + const winningLinesMap = this.getWinningLinesMap([1, 2]); + this.winningLines = []; + for (const player of [1, 2] as playerid[]) { + if (winningLinesMap.get(player)!.length > 0) { + this.winningLines.push(...winningLinesMap.get(player)!); + } + } + + // a player with a 4 in-a-row wins, but a 3 in-a-row loses + if (this.hasThrees(1)) { + this.gameover = true; + this.winner = [this.hasFours(1) ? 1 : 2]; + } + if (this.hasThrees(2)) { + this.gameover = true; + this.winner = [this.hasFours(2) ? 2 : 1]; + } + + // if the board is full it is a draw + if (!this.gameover) { + if (this.stack.length == this.boardSize * this.boardSize) { + this.gameover = true; + this.winner = [1, 2]; + } + } + + if (this.gameover) { + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + return this; + } + + public state(): ISquavaState { + return { + game: SquavaGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + } + + public moveState(): IMoveState { + return { + _version: SquavaGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + winningLines: this.winningLines.map(a => [...a]), + }; + } + + 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 = { + board: { + style: "squares-checkered", + width: this.boardSize, + height: this.boardSize, + }, + legend: { + A: [{ name: "piece", colour: 1 }], + B: [{ name: "piece", colour: 2 }], + }, + pieces: pstr + }; + + // 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 === "place") { + const coordsAll = this.renderAlgebraic2coords(move.where!); + for (const [x, y] of coordsAll) { + rep.annotations.push({ type: "enter", targets: [{ row: y, col: x }] }); + } + } + } + const renderWinningLines = this.renderWinningLines(this.winningLines); + if (renderWinningLines.length > 0) { + for (const connPath of renderWinningLines) { + if (connPath.length === 1) { continue; } + type RowCol = {row: number; col: number;}; + const targets: RowCol[] = []; + for (const coords of connPath) { + targets.push({row: coords[1], col: coords[0]}) + } + rep.annotations.push({type: "move", targets: targets as [RowCol, ...RowCol[]], arrow: false}); + } + } + } + return rep; + } + + /** + * 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; + } + + public clone(): SquavaGame { + return new SquavaGame(this.serialize()); + } +} \ No newline at end of file