diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 0fee2230..9f952ec8 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -189,6 +189,7 @@ "slither": "Square-board connection game with updated rules from 2018. Optionally move a piece and then place a piece, such that no two pieces of a colour are diagonally adjacent to each other unless they are orthogonally connected by a like-coloured stone. In Advanced Slither, a piece may only move if it is part of a contiguous orthogonal group consisting of pieces of both colours.", "slimetrail": "Push the ball piece to an adjacent empty cell and bring it to your goal, or stalemate the adversary.", "slyde": "In Slyde, the goal is to form the largest orthogonal group of your colour. Pieces can be fixed or mobile. The game starts in a checkerboard position and all pieces start mobile. On your turn, you may swap a mobile piece with an adjacent mobile piece of your opponent, and your piece becomes fixed. The game ends when one player runs out of moves. If the board is in a mirror-symmetric position after the first turn, you may change the state of a piece instead of performing the standard swap.", + "soccolot": "Use your team to run, dribble and kick the ball into the opponent's first row.", "spire": "On your turn, either place a ball of your colour, or place a neutral ball and then place a ball of your colour. No 2x2 area square of balls may include more than 2 like-coloured balls, and a ball of a colour cannot be placed on a platform containing two balls of that colour. The first player to run out of moves loses.", "spline": "Make a full line of your colour in any layer to win.", "sploof": "Create a string of four touching balls at any level visible as a straight line from directly above the board. Players start with two balls in their hand, but can take two balls from their stash every time a neutral ball is removed. Players also lose when they have no moves on their turn.", @@ -294,6 +295,7 @@ "shapechess": "Shape Chess, 形棋 (shape - board game), was designed c.2010 by 日出 (Richu) from Guangzhou, China. The game has evolved into its current form through years of refinement within the Chinese game design community (quoted from Kanare_Abstract). This is not a Chess-like game, but one that uses a very original concept by making players build reflectional symmetric shapes. This implementation includes several board sizes, and also different winning scores, for players who prefer shorter or longer matches.", "sentinel": "Vigil Games are a class of strategy games in which players must maintain an uninterrupted line of sight between their pieces and a designated reference point or region of the board. The first vigil game on record is 1892's Kastellet.\n\nSentinel is a vigil game where players always need to have at least one line-of-sight to the board's center. Sentinel is the second abstract game of this genre (after more than 130 years!), mixing typical movement and capture ludemes with permanent line-of-sight control of the central square.\n\nThe game mixes forward movement with sowing stacks which are the only way for armies to increase their size.", "slimetrail": "Slimetrail was designed by Bill Taylor in 1992. Initially the game was played on the Hex board and a stalemate was a draw. The game evolved to be played on a 8x8 board, where a stalemated player loses the match. This version was used in the Portuguese Tournament of Board Games for several years. There are some 1970s precursors of this theme, like Knight Chase, Blokeo, ZigZag and Tesoro (cf. WAG's page in the links).", + "soccolot": "Soccolot is a 1972 game by David Wilson. The original game was played on a 8x8 board with six men per team. Players can move, or can interact with the ball either by shooting it (kick) or by moving it together with one team men (dribble). There is a constant struggle to gain control of the board and to find an opening to move or shoot the ball to the player's last row. The main variant is faster, played on a 7x7 board for better piece interaction. Robert A. Kraus implemented both variants for [Zillions](https://www.zillions-of-games.com/cgi-bin/zilligames/submissions.cgi?do=show;id=802).", "spire": "In this implementation, if you select only one space, it assumes that you placed the ball of your colour, and if you select two spaces, the first space is for the neutral ball and the second space is for the ball of your colour. If the first click is on a space where only one of the neutral ball or the ball of your colour is valid, it will automatically commit that ball.", "spook": "When using the randomised board setup, the only fairness heuristics that we currently have are that (1) the number of balls in solid 5-ball pyramids of the same colour are equal for both players and (2), the second-highest layer must contain balls of both colours. Feel free to contact us on Discord if you think of other ways to make the game fairer.", "spora": "Spora takes the concepts like group, liberty and territory, known from territorial games like Go, and adds support for stacks. Stacks can sow their pieces to adjacent intersections, capturing enemy pieces or supporting near friendly structures. The total number of pieces is limited, so players need to manage their finite budget to reach endgame controlling as much territory as possible. The limited budget implies that players must measure well when and how to deploy stacks with multiple pieces (like having an arsenal of daggers, but always being hesitant when to use them)\n\nSpora is the Greek σπορά, meaning 'sowing'.", @@ -2517,6 +2519,14 @@ "name": "16x16 board" } }, + "soccolot": { + "#board": { + "name": "7x7 board (5 men)" + }, + "original": { + "name": "8x8 board (6 men)" + } + }, "spire": { "#board": { "name": "4x4 board" @@ -5887,6 +5897,15 @@ "SELECT_DESTINATION": "Select a destination to move the piece to.", "SELECT_DESTINATION_OR_CHANGE": "Select a destination to move the piece to, or select the piece again to change its state." }, + "soccolot" : { + "INITIAL_INSTRUCTIONS": "Select a man to run or to dribble, or select the ball to kick it.", + "ERROR_SELECT": "Select either a friendly piece or the ball!", + "MAN_INSTRUCTIONS": "The selected man can run to an empty adjacent cell, or select the ball (if adjacent) to dribble with it", + "BALL_INSTRUCTIONS": "Now selected an adjacent friendly man to kick the ball in the opposite direction", + "ERROR_KICK_DRIBBLE": "Either (a) select a man to run, or (b) select a man and then select the adjacent ball, or (c) select the ball and then an adjacent man!", + "DRIBBLE_INSTRUCTIONS": "Click an adjacent empty cell from the ball, to move both man and ball in that direction (those cells must be both empty). It is also possible for the ball (or man) to move into the man's (or ball's) current position (if the other moves to an empty cell).", + "KICK_INSTRUCTIONS": "Click an empty cell in the opposite direction of the man, to move the ball. Notice that the ball cannot jump over other pieces." + }, "spire": { "CANNOT_PLACE": "{{where}} is not a valid place to put a ball.", "INITIAL_INSTRUCTIONS": "Select a space to a ball of your colour, or a neutral ball before placing ball of your colour.", diff --git a/src/games/index.ts b/src/games/index.ts index 845c6566..06203605 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -244,6 +244,7 @@ import { YGame, IYState } from "./y"; import { ShapeChessGame, IShapeChessState } from "./shapechess"; import { SlimetrailGame, ISlimetrailState } from "./slimetrail"; import { CatsDogsGame, ICatsDogsState } from "./catsdogs"; +import { SoccolotGame, ISoccolotState } from "./soccolot"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -491,6 +492,7 @@ export { ShapeChessGame, IShapeChessState, SlimetrailGame, ISlimetrailState, CatsDogsGame, ICatsDogsState, + SoccolotGame, ISoccolotState, }; const games = new Map(); // Manually add each game to the following array [ @@ -613,7 +616,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."); @@ -1113,6 +1116,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new SlimetrailGame(...args); case "catsdogs": return new CatsDogsGame(...args); + case "soccolot": + return new SoccolotGame(...args); } return; } diff --git a/src/games/soccolot.ts b/src/games/soccolot.ts new file mode 100644 index 00000000..c8aac903 --- /dev/null +++ b/src/games/soccolot.ts @@ -0,0 +1,561 @@ +import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep, Colourfuncs } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { allDirections, SquareGraph, reviver, UserFacingError } from "../common"; +import i18next from "i18next"; + +export type playerid = 1 | 2 | 3; // 3 is the ball + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; +}; + +export interface ISoccolotState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class SoccolotGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Soccolot", + uid: "soccolot", + playercounts: [2], + version: "20260509", + dateAdded: "2026-05-09", + // i18next.t("apgames:descriptions.soccolot") + description: "apgames:descriptions.soccolot", + // i18next.t("apgames:notes.soccolot") + notes: "apgames:notes.soccolot", + urls: [ + "https://www.zillions-of-games.com/cgi-bin/zilligames/submissions.cgi?do=show;id=802", ], + people: [ + { + type: "designer", + name: "David Wilson", + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + categories: ["goal>breakthrough", "mechanic>move", "board>shape>rect", "components>simple>1per"], + variants: [ + { uid: "#board", }, // Speed Soccolot + { uid: "original", group: "ruleset" }, + ], + flags: ["experimental"] + }; + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Map; + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + private ruleset: "default" | "original"; + private _points: [number, number][] = []; // if there are points here, the renderer will show them + + constructor(state?: ISoccolotState | string, variants?: string[]) { + super(); + if (state === undefined) { + if ( (variants !== undefined) && (variants.length > 0) ) { + this.variants = [...variants]; + } + this.ruleset = this.getRuleset(); + let board: Map; + if ( this.ruleset === "original" ) { + board = new Map([ // initial setup + ["b1", 1], ["c1", 1], ["d1", 1], ["e1", 1], ["f1", 1], ["g1", 1], + ["b8", 2], ["c8", 2], ["d8", 2], ["e8", 2], ["f8", 2], ["g8", 2], + ["d4", 3], + ]); + } else { + board = new Map([ // initial setup + ["b2", 1], ["c2", 1], ["d2", 1], ["e2", 1], ["f2", 1], + ["b6", 2], ["c6", 2], ["d6", 2], ["e6", 2], ["f6", 2], + ["d4", 3], + ]); + } + const fresh: IMoveState = { + _version: SoccolotGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as ISoccolotState; + } + if (state.game !== SoccolotGame.gameinfo.uid) { + throw new Error(`The Soccolot 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(); + this.ruleset = this.getRuleset(); + } + + public load(idx = -1): SoccolotGame { + 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]; + return this; + } + + private getRuleset(): "default" | "original" { + if (this.variants.includes("original")) { return "original"; } + return "default"; + } + + public get boardsize(): number { + return this.ruleset === "original" ? 8 : 7; + } + + private get graph(): SquareGraph { + return new SquareGraph(this.boardsize, this.boardsize); + } + + // return the coordinates where the ball is + private getBall(): string { + return [...this.board.entries()].filter(e => e[1] === 3).map(e => e[0])[0]; + } + + public moves(player?: playerid): string[] { + if (this.gameover) { return []; } + if (player === undefined) { player = this.currplayer; } + const grid = this.graph; + const moves = []; + + // there are three types of moves, + // a) run: moves a Man to any empty adjacent square (eg, man-newman) + // b) dribble: Ball and adjacent Man both move is the same direction to empty cell (eg, man,ball-newman) + // c) kick: move the Ball in the direction away from the adjacent Man (eg, ball,man>newball) + + // run + for (const cell of this.graph.graph.nodes()) { + if ( !this.board.has(cell) || this.board.get(cell)! !== player ) { continue; } + for (const adj of grid.neighbours(cell)) { + if (! this.board.has(adj) ) { + moves.push(`${cell}-${adj}`); + } + } + } + + // dribble + const ball = this.getBall(); + const g = this.graph; + const [xb, yb] = g.algebraic2coords(ball); + for (const man of grid.neighbours(ball)) { + // find adjacent friendly Men + if ( this.board.has(man) && this.board.get(man)! === player ) { + const [xm, ym] = g.algebraic2coords(man); + for (const dir of allDirections) { + const rayBall = g.ray(xb, yb, dir); + const rayMan = g.ray(xm, ym, dir); + // if both next cells, in the current direction, exist and are empty, add dribble move + if ( rayBall.length > 0 && rayMan.length > 0 ) { + const cellNewBall = g.coords2algebraic(...rayBall[0]); + const cellNewMan = g.coords2algebraic(...rayMan[0]); + // It is also possible for the ball (or man) to move into the man's (or ball's) + // current position (if the other moves to an empty cell). + if ( (!this.board.has(cellNewBall) || cellNewBall === man ) && + (!this.board.has(cellNewMan) || cellNewMan === ball ) ) { + moves.push(`${man},${ball}-${cellNewMan}`); + } + } + } + } + } + + // kick + for (const man of grid.neighbours(ball)) { + // find adjacent friendly Men + if ( this.board.has(man) && this.board.get(man)! === player ) { + const [xm, ym] = g.algebraic2coords(man); + for (const dir of allDirections) { + const ray = this.graph.ray(xm, ym, dir); + if ( ray.length > 0 ) { + // the only direction is the one towards the ball + let nextCell = g.coords2algebraic(...ray[0]); + if (nextCell !== ball) { continue; } + for (let i=1; i${nextCell}`); + } + break; + } + } + } + } + + return moves.sort((a,b) => a.localeCompare(b)); + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const cell = this.graph.coords2algebraic(col, row); + const moves: string[] = move.split(/[,>-]/); + let newmove = ""; + + if ( move === "" ) { + newmove = cell; + } else if ( move === cell ) { // reclick resets the move + newmove = ""; + } else if (moves.length === 1) { + if ( this.board.has(cell) ) { + newmove = `${move},${cell}`; // kick or dribble (partial) + } else { + newmove = `${move}-${cell}`; // run (final) + } + } else if (moves.length === 2) { + if ( moves[0] === this.getBall() ) { // it is a kick (final) + newmove = `${move}>${cell}`; + } else { // it is a dribble (final) + newmove = `${move}-${cell}`; + } + } else { + newmove = ""; // something went wrong, reset move + } + + 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}) + } + } + } + + private hasPrefix(moves: string[], partial: string): boolean { + return moves.some(str => str.startsWith(partial)); + } + + 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.canrender = true; + result.message = i18next.t("apgames:validation.soccolot.INITIAL_INSTRUCTIONS"); + return result; + } + + const prevplayer = this.currplayer % 2 + 1 as playerid; + const moves: string[] = m.split(/[,>-]/); + + if ( moves.length === 1 ) { + if ( !this.board.has(m) || this.board.get(m)! === prevplayer ) { + result.valid = false; + result.message = i18next.t("apgames:validation.soccolot.ERROR_SELECT"); + return result + } + result.valid = true; + result.complete = -1; + result.canrender = true; + if (this.board.get(m)! === this.currplayer) { + result.message = i18next.t("apgames:validation.soccolot.MAN_INSTRUCTIONS"); + } else { + result.message = i18next.t("apgames:validation.soccolot.BALL_INSTRUCTIONS"); + } + return result; + } + + const allMoves = this.moves(); + + if ( moves.length === 2 ) { + if (! this.hasPrefix(allMoves, m) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.soccolot.ERROR_KICK_DRIBBLE"); + return result + } + + if ( m.includes('-') ) { // a run is a complete valid move + result.valid = true; + result.complete = 1; + result.canrender = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + // otherwise it is either a dribble or a kick + result.valid = true; + result.complete = -1; + result.canrender = true; + if (this.board.get(moves[0])! === this.currplayer) { + result.message = i18next.t("apgames:validation.soccolot.DRIBBLE_INSTRUCTIONS"); + } else { + result.message = i18next.t("apgames:validation.soccolot.KICK_INSTRUCTIONS"); + } + return result; + } + + if (! allMoves.includes(m) ) { + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALID_MOVE", {move: m}); + return result + } + + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + // return the list of cells the current move can go to + private findPoints(move: string): string[] { + const moves = move.split(/[,>-]/); + const allMoves = this.moves(); + const ball = this.getBall(); + const res = []; + + if ( !move.includes('-') && !move.includes(',') && this.board.get(move) === this.currplayer ) { + // show available runs + res.push(...allMoves.filter(m => !m.includes(',')) + .map(m => m.split('-')) + .filter(([from,]) => from === move) + .map(([, to]) => to)); + if (this.hasPrefix(allMoves, `${move},${ball}`)) { + res.push(ball); + } + } else if ( !move.includes('-') && !move.includes(',') && move === ball ) { + // show available men to kick (select moves starting as ball,man>newball) + res.push(...allMoves.filter(m => m.startsWith(ball)) + .map(m => m.split(/[,>]/)[1])); + } else if ( moves[0] === ball || moves[1] === ball ) { + // show available places to dribble (select moves like man,ball-newman) + // or to kick (select moves like ball,man>newball) + res.push(...allMoves.filter(m => m.startsWith(move)) + .map(m => m.split(/[,>-]/)[2])); + } + + return res; + } + + public move(m: string, {partial = false, trusted = false} = {}): SoccolotGame { + 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) + } + } + + this.results = []; + const moves = m.split(/[,>-]/); + + if ( partial ) { // if partial, set the points to be shown + const g = this.graph; + this._points = this.findPoints(m).map(c => g.algebraic2coords(c)); + return this; + } else { + this._points = []; // otherwise delete the points and process the full move + } + + if ( moves.length === 2 && m.includes('-') ) { // a run + this.board.delete(moves[0]); + this.board.set(moves[1], this.currplayer); + this.results.push({ type: "move", from: moves[0], to: moves[1] }); + } else if ( m.includes('-') ) { // a dribble (man,ball-newball) + const g = this.graph; + const [xm0, ym0] = g.algebraic2coords(moves[0]); // man old coord + const [xm1, ym1] = g.algebraic2coords(moves[2]); // man new coord + const dx = xm1 - xm0; // compute direction of dribble + const dy = ym1 - ym0; + const [xb, yb] = g.algebraic2coords(moves[1]); // ball old coord + const newBall = g.coords2algebraic(xb + dx, yb + dy); // ball new cell + //move the man and the ball + this.board.delete(moves[0]); // remove old man + this.board.delete(moves[1]); // remove old ball + this.board.set(moves[2], this.currplayer); + this.results.push({ type: "move", from: moves[0], to: moves[2] }); + this.board.set(newBall, 3); + this.results.push({ type: "move", from: moves[1], to: newBall }); + } else { // a kick (ball,man>newball) + this.board.delete(moves[0]); + this.board.set(moves[2], 3); // moving the ball + this.results.push({ type: "move", from: moves[0], to: moves[2] }); + } + + if (partial) { return this; } + + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + this.checkEOG(); + this.saveState(); + return this; + } + + protected checkEOG(): SoccolotGame { + const ball = this.getBall(); + + if (Number(ball.slice(1)) === this.boardsize) { + this.gameover = true; + this.winner = [1]; + } + + if (ball.slice(1) === '1') { + this.gameover = true; + this.winner = [2]; + } + + if ( this.gameover ) { + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + + return this; + } + + public state(): ISoccolotState { + return { + game: SoccolotGame.gameinfo.uid, + numplayers: this.numplayers, + variants: [...this.variants], + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + } + + public moveState(): IMoveState { + return { + _version: SoccolotGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + }; + } + + public render(): APRenderRep { + const g = this.graph; + // Build piece string + let pstr = ""; + for (const row of g.listCells(true) as string[][]) { + if (pstr.length > 0) { + pstr += "\n"; + } + const pieces: string[] = []; + for (const cell of row) { + if (this.board.has(cell)) { + const contents = this.board.get(cell); + if (contents === 1) { + pieces.push("A"); + } else if (contents === 2) { + pieces.push("B"); + } else { + pieces.push("C"); + } + } else { + pieces.push("-"); + } + } + pstr += pieces.join(""); + } + + const ballColour: Colourfuncs = { + func: "custom", + default: "#FFDF00", // gold yellow + palette: 3 + }; + + const size = this.boardsize; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const markers: Array = [ + { + type: "shading", + colour: this.getPlayerColour(1), + points: [{row:size, col:0}, {row:size, col:size}, {row:size-1, col:size}, {row:size-1, col:0} ] + }, + { + type: "shading", + colour: this.getPlayerColour(2), + points: [{row:0, col:0}, {row:0, col:size}, {row:1, col:size}, {row:1, col:0} ] + } + ]; + + // Build rep + const rep: APRenderRep = { + board: { + style: "squares-checkered", + width: this.boardsize, + height: this.boardsize, + markers + }, + legend: { + A: { name: "piece", colour: this.getPlayerColour(1) }, + B: { name: "piece", colour: this.getPlayerColour(2) }, + C: { name: "piece", colour: ballColour }, + }, + pieces: pstr + }; + + rep.annotations = []; + for (const move of this.results) { + if (move.type === "move") { + const [fromX, fromY] = g.algebraic2coords(move.from); + const [toX, toY] = g.algebraic2coords(move.to); + rep.annotations.push({type: "move", targets: [{row: fromY, col: fromX}, {row: toY, col: toX}]}); + } + } + + // show the dots where the selected piece can move to + if (this._points.length > 0) { + const points = []; + for (const [x,y] of this._points) { + points.push({row: y, col: x}); + } + rep.annotations.push({type: "dots", + targets: points as [{row: number; col: number;}, ...{row: number; col: number;}[]]}); + } + + return rep; + } + + public getPlayerColour(p: playerid): Colourfuncs { + if (p === 1) { + return { func: "custom", default: 1, palette: 1 }; + } else { + return { func: "custom", default: 2, palette: 2 }; + } + } + + public clone(): SoccolotGame { + return new SoccolotGame(this.serialize()); + } +}