diff --git a/locales/en/apgames.json b/locales/en/apgames.json index e117e89f..99a3d2bc 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -186,6 +186,7 @@ "shifty": "A connection game where you either place a piece orthogonally adjacent to a friendly piece, or move one of your pieces orthogonally adjacent to a friendly piece, by a chess queen's move, to a location not orthogonally adjacent to any other friendly pieces. Crosscuts are illegal. The player that forms an orthogonal or diagonal path connecting their two sides wins.", "siegeofj": "Place cards, representing armies, to exert pressure on segments of the wall around the city of Jacynth. Exert the most pressure on the most segments to win.", "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.", "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.", @@ -290,6 +291,7 @@ "siegeofj": "More information on the Decktet system can be found on the [official Decktet website](https://www.decktet.com). Cards in players' hands are hidden from observers, and they are hidden from opponents until the deck is empty, at which point the players have perfect information, so the hands are revealed.", "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).", "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'.", @@ -2458,6 +2460,16 @@ "description": "Deals all 24 cards to the players at the beginning of the game. All hands are visible." } }, + "slimetrail": { + "#board": { + "description": "8x8 board game", + "name": "8x8 board" + }, + "rhombus11": { + "name": "Hex board", + "description": "Hex board of size 11." + } + }, "slither": { "#board": { "name": "9x9 board" @@ -5822,6 +5834,10 @@ "LABEL_STASH": "Player {{playerNum}}'s hand", "VALID_BUT": "That placement is valid. It's also possible to span two cards. Click \"Complete Move\" to keep the single parent, or click the adjacent card to span them both." }, + "slimetrail": { + "INITIAL_INSTRUCTIONS": "Move the ball to an adjacent empty cell.", + "INVALID_MOVE": "The chosen cell must be empty and adjacent to the ball." + }, "slither": { "CANNOT_MOVE": "Advanced Slither: the piece at {{where}} cannot move because it is not part of a contiguous orthogonal group consisting of pieces of both colors.", "DIAGONAL": "You may not place at {{where}} because it is diagonally adjacent to a piece of your colour without being orthogonally connected.", diff --git a/src/games/index.ts b/src/games/index.ts index bd02af24..d527d599 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -242,6 +242,7 @@ import { DomineeringGame, IDomineeringState } from "./domineering"; import { TwinFlamesGame, ITwinFlamesState } from "./twinflames"; import { YGame, IYState } from "./y"; import { ShapeChessGame, IShapeChessState } from "./shapechess"; +import { SlimetrailGame, ISlimetrailState } from "./slimetrail"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -487,6 +488,7 @@ export { TwinFlamesGame, ITwinFlamesState, YGame, IYState, ShapeChessGame, IShapeChessState, + SlimetrailGame, ISlimetrailState, }; const games = new Map(); // Manually add each game to the following array [ @@ -608,7 +610,8 @@ 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."); @@ -1104,6 +1107,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new YGame(...args); case "shapechess": return new ShapeChessGame(...args); + case "slimetrail": + return new SlimetrailGame(...args); } return; } diff --git a/src/games/slimetrail.ts b/src/games/slimetrail.ts new file mode 100644 index 00000000..04216c04 --- /dev/null +++ b/src/games/slimetrail.ts @@ -0,0 +1,381 @@ +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 { SquareGraph, reviver, UserFacingError } from "../common"; +import { HexSlantedGraph } from "../common/graphs"; +import i18next from "i18next"; + +export type playerid = 1 | 2; // regarding pieces: 1 is the ball, 2 are the walls + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; +}; + +export interface ISlimetrailState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class SlimetrailGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Slimetrail", + uid: "slimetrail", + playercounts: [2], + version: "20260508", + dateAdded: "2026-05-08", + // i18next.t("apgames:descriptions.slimetrail") + description: "apgames:descriptions.slimetrail", + // i18next.t("apgames:notes.slimetrail") + notes: "apgames:notes.slimetrail", + urls: [ + "https://boardgamegeek.com/boardgame/31467/slimetrail", + "https://jpneto.github.io/world_abstract_games/slimetrail.htm", + ], + people: [ + { + name: "Bill Taylor", + urls: ["https://boardgamegeek.com/boardgamedesigner/9249/bill-taylor"], + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + variants: [ + { uid: "#board" }, + { uid: "rhombus11", group: "board" }, + ], + categories: ["goal>breakthrough", "mechanic>move", "mechanic>block", + "board>shape>rect", "board>shape>hex", "components>simple>1per"], + flags: ["automove", "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 = []; + + constructor(state?: ISlimetrailState | string, variants?: string[]) { + super(); + if (state === undefined) { + if ( (variants !== undefined) && (variants.length > 0) ) { + this.variants = [...variants]; + } + const ball = this.variants.includes("rhombus11") ? "g5" : "e5"; + const board: Map = new Map([ [ball, 1] ]); + + const fresh: IMoveState = { + _version: SlimetrailGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as ISlimetrailState; + } + if (state.game !== SlimetrailGame.gameinfo.uid) { + throw new Error(`The Slimetrail 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): SlimetrailGame { + 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; + } + + public get boardsize(): number { + if (this.variants.includes("rhombus11")) { + return 11; + } + return 8; + } + + public get graph(): SquareGraph | HexSlantedGraph { + if (this.variants.includes("rhombus11")) { + return new HexSlantedGraph(this.boardsize, this.boardsize); + } else { + 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] === 1).map(e => e[0])[0]; + } + + private neighborsBall(): string[] { + /*const neigh: string[] = []; + const grid = this.graph; + + for (const adj of grid.neighbours(this.getBall())) { + if ( !this.board.has(adj) ) { + neigh.push(adj); + } + } + return neigh;*/ + return [...this.graph.neighbours(this.getBall())].filter(c => !this.board.has(c)); + } + + // get the goal cell for the given player + private getGoal(player: playerid): string { + if (this.variants.includes("rhombus11")) { + return player === 1 ? "a11" : "k1"; + } else { + return player === 1 ? "a1" : "h8"; + } + } + + public moves(): string[] { + if (this.gameover) { return []; } + return this.neighborsBall(); + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const newmove = this.graph.coords2algebraic(col, row); + 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")}; + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + result.canrender = true; + result.message = i18next.t("apgames:validation.slimetrail.INITIAL_INSTRUCTIONS"); + return result; + } + + const allMoves = this.moves(); + if (! allMoves.includes(m) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.slimetrail.INVALID_MOVE"); + return result + } + + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + public move(m: string, {partial = false, trusted = false} = {}): SlimetrailGame { + 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 ( (! partial) && (! this.moves().includes(m)) ) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) + } + } + + this.results = []; + const ball = this.getBall(); + this.board.set(ball, 2); // where the ball was becomes a wall... + this.board.set(m, 1); // and the ball moves to the new cell + this.results.push({ type: "move", from: ball, to: m }); + + if (partial) { return this; } + + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + this.checkEOG(); + this.saveState(); + return this; + } + + protected checkEOG(): SlimetrailGame { + const prevPlayer: playerid = this.currplayer % 2 + 1 as playerid; + const ball = this.getBall(); + + if ( ball === this.getGoal(1) ) { + this.gameover = true; + this.winner = [1]; + } else if ( ball === this.getGoal(2) ) { + this.gameover = true; + this.winner = [2]; + } else if ( this.neighborsBall().length === 0 ) { + this.gameover = true; + this.winner = [prevPlayer]; + } + + if ( this.gameover ) { + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + + return this; + } + + public state(): ISlimetrailState { + return { + game: SlimetrailGame.gameinfo.uid, + numplayers: this.numplayers, + variants: [...this.variants], + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + } + + public moveState(): IMoveState { + return { + _version: SlimetrailGame.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 { + pieces.push("B"); + } + } else { + pieces.push("-"); + } + } + pstr += pieces.join(""); + } + + const ballColour: Colourfuncs = { + func: "custom", + default: "#FFDF00", // gold yellow + palette: 3 + }; + + const wallColour: Colourfuncs = { + func: "custom", + default: "#999", + palette: 4 + }; + + const isHex = this.variants.includes("rhombus11"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let markers : Array = []; + if ( isHex ) { + markers = [ + { + type: "flood", + colour: this.getPlayerColour(1), + points: [{row:10, col:0} ] + }, + { + type: "flood", + colour: this.getPlayerColour(2), + points: [{row:0, col:10} ] + }, + ]; + } else { + markers = [ + { + type: "shading", + colour: this.getPlayerColour(1), + points: [{row:8, col:0}, {row:8, col:1}, {row:7, col:1}, {row:7, col:0} ] + }, + { + type: "shading", + colour: this.getPlayerColour(2), + points: [{row:1, col:7}, {row:1, col:8}, {row:0, col:8}, {row:0, col:7} ] + }, + ]; + } + + // Build rep + const rep: APRenderRep = { + board: isHex ? { style: "hex-slanted", width: this.boardsize, height: this.boardsize, markers } : + { style: "squares-checkered", width: this.boardsize, height: this.boardsize, markers }, + legend: { + A: { name: "piece", colour: ballColour }, + B: { name: "piece", colour: wallColour }, + }, + 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}]}); + } + } + + 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(): SlimetrailGame { + return new SlimetrailGame(this.serialize()); + } +}