From a4518fb24b71efb94cf6007bb4f3b519a21e3ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= Date: Mon, 1 Jun 2026 13:35:43 +0100 Subject: [PATCH] Added Linage --- locales/en/apgames.json | 32 +++ src/games/ayu.ts | 1 + src/games/index.ts | 9 +- src/games/linage.ts | 548 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 588 insertions(+), 2 deletions(-) create mode 100644 src/games/linage.ts diff --git a/locales/en/apgames.json b/locales/en/apgames.json index f4f7c1f2..6df4772b 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -121,6 +121,7 @@ "lasca": "A Draughts variant where captured pieces remain on the board and can be freed later. Immobilize your opponent to win.", "lielow": "A wartime tale of Machiavellian usurpership. You win if your piece lands on your opponent's king. You also win if your opponent chooses to move their king off the board.", "lifeline": "Conquer territory and capture enemy groups by cutting them off from all their allies. A group is cut off when it has no free path (straight or otherwise) to other groups of its color. Win by leaving your opponent with no moves.", + "linage": "A territorial game played with just one color.", "loa": "A classic game where you try to gather all your pieces into a single connected group. Pieces can only move the exact number of spaces as the number of pieces that lie along the line of movement. The \"Scrambled Eggs\" initial layout variant is supported.", "logger": "A Looney pyramids game where the players vie to grow and harvest more trees than their opponents.", "lox": "Hex connection game with captures inspired by Tumbleweed. A cell is controlled by a player if it has more lines of sight to the player's pieces than half of the number neighbours that it has. If an opponent's piece is on your controlled cell, you may capture it by replacing it with your own piece on your turn. The player with a solid connection between their two sides at the start of their turn wins.", @@ -284,6 +285,7 @@ "jacynth": "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 opponents.", "konane": "Several competing opening protocols exist, but the most common ruleset is the Naihe Ruleset, used by tournaments at the Bishop Museum in Hawaii and described in the BGG reference. This is what is implemented here.", "lasca": "Maneuverability is a measure of how close your pieces are to promoting. Your maximum maneuverability is the board size times the number of stacks you control.\n\nMaterial is calculated by giving you a point for every friendly piece in a stack you control, plus an extra point if you have an officer on top of that stack.", + "linage" : "Linage is a 2016 game designed by Luis Bolaños Mures. The game is based on the following definitions: A **region** is a maximal set of orthogonally contiguous empty points. A **line** is a set of three orthogonally contiguous empty points on the same row or column; and a region is **owned** by Vertical if it contains no horizontal lines, and by Horizontal if it contains no vertical lines. A region is free if it is owned by neither player. Regions must always have at least one line.\n\nOn his turn, the player passes or places a stone in a free region. The game ends when both players pass in succession, and wins the one with highest score, i.e., the number of points in the regions the player owns, plus komi.", "loa": "In the centre of the 9x9 board is a \"black hole.\". Landing on the black hole means the piece is removed from the game. Simultaneous connections are scored as a draw.", "magnate": "The terminology of some Magnate actions has been altered for clarity and brevity. Completely developing a new property is called the \"Buy\" action; purchasing a deed for a new property is called \"Deed\", developing deeds (that is, adding tokens to a deeded property, whether it results in the deed becoming fully developed or not) is called \"Add\". (Selling a card and trading suit tokens 3 for 1 are unchanged.)\n\nIn order to speed up the process of rolling for resources, there are two additional actions:\n* \"Prefer\" is for setting your preference of which suit token to take when a deed pays out on your opponent's roll. If you do not set an explicit preference, the code will choose the rarer token for you based on your non-crown suits and current supply of tokens. The currently preferred token is circled in the UI, but your personal preference is never visible to the other player.\n* \"Choose\" is a mandatory first action for collecting suit tokens when a deed pays out on your own roll. (In all cases where you need to choose a suit token that is not already among your tokens, you still click on the appropriate token pile.)\n\nBecause you can perform several actions during a ply in any order, there is also an \"Undo\" action to back out your most recent action, whether or not it was complete.\n\nNote that only the final resource die result is displayed, but the distribution of expected outcomes is still that of rolling 2d10 and taking the higher value. Taxation happens when the lower of 2d10 comes up 1; a suit die is rolled (or two, in the double taxation variant), and the suits will be displayed underneath the resource result. The roll is logged at the end of a player's turn, and is attributed to the next player (who would have rolled in the physical game). Except for a \"Choose\", no user action is required; resources are added or removed automatically by the server in between turns.\n\nWhen a player is ahead in a district, the Pawn or Excuse for that district is outlined in that player's color. The first tiebreaker score (total property value) is displayed in parentheses after the district score. The second tiebreaker is total number of tokens remaining.", "mchess": "If there have been seven consecutive turns without a capture, someone can \"call the clock\" by adding an asterisk (*) to the end of their move. This can only be done by selecting the move from the drop-down list. After another seven turns with no capture, the game will end and be scored.", @@ -591,6 +593,9 @@ }, "size-17": { "name": "17x17 board" + }, + "size-19": { + "name": "19x19 board" } }, "bamboo": { @@ -1686,6 +1691,23 @@ "name": "Size 12 board" } }, + "linage": { + "size-11": { + "name": "11x11 board" + }, + "size-13": { + "name": "13x13 board" + }, + "#board": { + "name": "15x15 board" + }, + "size-17": { + "name": "17x17 board" + }, + "size-19": { + "name": "19x19 board" + } + }, "loa": { "#board": { "description": "9x9 \"black hole\" variant. Moving a piece onto the central hole removes the piece from the game.", @@ -5249,6 +5271,16 @@ "TOO_MANY_PIECES": "Too many pieces.", "INCURSION": "The cell at {{cell}} has no path to a friendly group." }, + "linage": { + "INITIAL_SETUP": "Choose a number of points to add to the second player's score, and the next player will choose sides. (0.5 will be added to Komi to prevent draws.)", + "INSTRUCTIONS": "Place a piece on a free region.", + "TABOO": "It is invalid to: (a) place on an owned region, (b) make a region without a horizontal or vertical line.", + "INVALID_KOMI": "You must choose an integer number of points to add to the second player's score.", + "INVALID_PASS": "You cannot pass currently.", + "OCCUPIED": "The placement must be on an empty cell.", + "INVALID_PLAYSECOND": "You cannot choose to play second from this board state.", + "KOMI_CHOICE": "You may either make the first move on the board and let your opponent keep the bonus points (an integer) or you may choose \"Play second\" and take the bonus points for yourself." + }, "loa": { "BAD_PASS": "You may not pass if legal moves are available.", "INITIAL_INSTRUCTIONS": "Select one of your pieces to move.", diff --git a/src/games/ayu.ts b/src/games/ayu.ts index 0ac6bead..10ee0a3c 100644 --- a/src/games/ayu.ts +++ b/src/games/ayu.ts @@ -51,6 +51,7 @@ export class AyuGame extends GameBase { { uid: "#board" }, { uid: "size-15", group: "board" }, { uid: "size-17", group: "board" }, + { uid: "size-19", group: "board" }, ], categories: ["goal>unify", "mechanic>move>group", "board>shape>rect", "board>connect>rect", "components>simple>1per"], flags: ["pie"], diff --git a/src/games/index.ts b/src/games/index.ts index 40fac4a8..074f781c 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -253,6 +253,7 @@ import { SynapseGame, ISynapseState } from "./synapse"; import { AtariGoGame, IAtariGoState } from "./atarigo"; import { TanboGame, ITanboState } from "./tanbo"; import { UnaneGame, IUnaneState } from "./unane"; +import { LinageGame, ILinageState } from "./linage"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -509,6 +510,7 @@ export { AtariGoGame, IAtariGoState, TanboGame, ITanboState, UnaneGame, IUnaneState, + LinageGame, ILinageState, }; const games = new Map(); // Manually add each game to the following array [ @@ -635,7 +638,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."); @@ -1153,6 +1156,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new TanboGame(...args); case "unane": return new UnaneGame(...args); + case "linage": + return new LinageGame(...args); } return; } diff --git a/src/games/linage.ts b/src/games/linage.ts new file mode 100644 index 00000000..03dabe54 --- /dev/null +++ b/src/games/linage.ts @@ -0,0 +1,548 @@ +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IValidationResult, IScores } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep, BoardBasic, MarkerDots, RowCol, Colourfuncs } 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"; + +// 1 is Vertical, 2 is Horizontal, 3 is for neutral pieces/free regions, 4 is for invalid regions +export type playerid = 1 | 2 | 3 | 4; + +type Territory = { + cells: string[]; + owner: playerid | undefined; +}; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; + komi?: number; + swapped: boolean; +}; + +export interface ILinageState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class LinageGame extends GameBase { + + public static readonly gameinfo: APGamesInformation = { + name: "Linage", + uid: "linage", + playercounts: [2], + version: "20260601", + dateAdded: "2026-06-01", + // i18next.t("apgames:descriptions.linage") + description: "apgames:descriptions.linage", + notes: "apgames:notes.linage", + urls: [ + "https://boardgamegeek.com/boardgame/219420/linage" + ], + 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-11", group: "board" }, + { uid: "size-13", group: "board" }, + { uid: "#board", }, // 15x15 + { uid: "size-17", group: "board" }, + { uid: "size-19", group: "board" }, + ], + flags: ["custom-buttons", "custom-colours", "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 komi?: number; + public swapped = true; + + constructor(state?: ILinageState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined) { + this.variants = [...variants]; + } + const board = new Map(); + const fresh: IMoveState = { + _version: LinageGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + swapped: true + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as ILinageState; + } + if (state.game !== LinageGame.gameinfo.uid) { + throw new Error(`The Linage 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): LinageGame { + 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.komi = state.komi; + this.swapped = false; + // We have to check the first state because we store the updated version in later states + if (state.swapped === undefined) { + this.swapped = this.stack.length < 3 || this.stack[2].lastmove !== "play-second"; + } else { + this.swapped = state.swapped; + } + 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 15; + } + + public isKomiTurn(): boolean { + return this.stack.length === 1; + } + + public isPieTurn(): boolean { + return this.stack.length === 2; + } + + private getGraph(): SquareOrthGraph { // just orthogonal connections + return new SquareOrthGraph(this.boardSize, this.boardSize); + } + + // return how many horizontal and vertical lines, of size three, are inside the given area + private getLines(area: Array) : [number,number] { + let hLines = 0, vLines = 0; + const cells = new Set(); + // make a set of all cells (using their coordinates), for quick search + for (const cell of area) { + const [x,y] = this.algebraic2coords(cell); + cells.add(`${x},${y}`); // used strings bc arrays are compared by reference, not by value + } + for (const cell of area) { + const [x,y] = this.algebraic2coords(cell); + // check right for 3 in-a-row + if (cells.has(`${x+1},${y}`) && cells.has(`${x+2},${y}`) ) { + hLines += 1; + } + // check below for 3 in-a-row + if (cells.has(`${x},${y+1}`) && cells.has(`${x},${y+2}`) ) { + vLines += 1; + } + } + + return [hLines, vLines]; + } + + // get all territories/regions either owned or free (or invalid) + private getTerritories(): Territory[] { + const allPieces = [...this.board.entries()].map(pair => pair[0]); + + // 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) { + let owner : playerid = 3; // default value: neutral area + const [hLines, vLines] = this.getLines(area); + if (hLines == 0 && vLines > 0) { + owner = 1; // vertical is player 1 + } + else if (vLines == 0 && hLines > 0) { + owner = 2; + } + else if (vLines == 0 && hLines == 0) { + owner = 4; // invalid region + } + territories.push({cells: area, owner}); + } + return territories; + } + + // does cell is in a owned region or, playing in it, does it creates an invalid region? + // requires: cell (x,y) is empty + private isTaboo(cell: string): boolean { + + // is in an owned territory? + for (const terr of this.getTerritories()) { + if ( terr.owner !== 3 && terr.cells.includes(cell) ) { + return true; // cannot play on owned regions + } + } + + // check if the move would create an invalid region + this.board.set(cell, this.currplayer); + const result = this.getTerritories().some(terr => terr.owner === 4); + this.board.delete(cell); + + return result; + } + + // Generates a full list of valid moves from the current game state. + public moves(): string[] { + return []; // costly to compute + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + if (this.isKomiTurn()) { // Komi time, so no clicks are acceptable + const dummyResult = this.validateMove("") as IClickResult; + dummyResult.move = ""; + dummyResult.valid = false; + return dummyResult; + } + const cell = this.coords2algebraic(col, row); + const result = this.validateMove(cell) as IClickResult; + result.move = result.valid ? cell : ""; + 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 (this.isKomiTurn()) { + if (m.length === 0) { + // game is starting, show initial KOMI message + result.valid = true; + result.complete = -1; + result.message = i18next.t("apgames:validation.linage.INITIAL_SETUP"); + return result; + } + + // player typed something in the move textbox, check if it is an integer + if (! /^-?\d+$/.test(m)) { + result.valid = false; + result.message = i18next.t("apgames:validation.linage.INVALID_KOMI"); + return result + } + result.valid = true; + result.complete = 0; // partial because player can continue typing for abs(Komi) > 9 + result.message = i18next.t("apgames:validation.linage.INSTRUCTIONS"); + return result; + } + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + if (this.isPieTurn()) { + result.message = i18next.t("apgames:validation.linage.KOMI_CHOICE"); + } else { + result.message = i18next.t("apgames:validation.linage.INSTRUCTIONS") + } + return result; + } + + if (m === "play-second") { + if (this.isPieTurn()) { + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + } else { + result.valid = false; + result.message = i18next.t("apgames:validation.linage.INVALID_PLAYSECOND"); + } + return result; + } + + if (m === "pass") { + if (this.isPieTurn()) { + result.valid = false; + result.message = i18next.t("apgames:validation.linage.INVALID_PASS"); + return result; + } + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + if ( this.board.has(m) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.linage.OCCUPIED"); + return result; + } + + // is it playing on a free region, or making an invalid region? + if ( this.isTaboo(m) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.linage.TABOO"); + return result; + } + + result.valid = true; + result.complete = 1; + result.canrender = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + public move(m: string, {partial = false, trusted = false} = {}): LinageGame { + 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, ""); + + if (! trusted) { + const result = this.validateMove(m); + if (! result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message) + } + } + + if (this.isKomiTurn()) { + // first move, get the Komi proposed value, and add komi to game state + this.komi = parseInt(m, 10); + this.results.push({type: "komi", value: this.komi}); + this.komi *= -1; // Invert it for backwards compatibility reasons + } else if (m === "play-second") { + this.komi! *= -1; + this.swapped = false; + this.results.push({type: "play-second"}); + } else if (m === "pass") { + this.results.push({type: "pass"}); + } else { + // piece placement (after the Komi+Pie phase) + this.results.push({ type: "place", where: m }); + this.board.set(m, this.currplayer); + } + + if (partial) { return this; } + + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + this.checkEOG(); + this.saveState(); + return this; + } + + private hasPlacements(): boolean { + return this.getTerritories().some(terr => terr.owner === 3); + } + + protected checkEOG(): LinageGame { + this.gameover = !this.hasPlacements() // all regions are owned + || + (this.lastmove === "pass" && // two consecutive passes occurred + this.stack[this.stack.length - 1].lastmove === "pass"); + + if (this.gameover) { + this.winner = this.getPlayerScore(1) > this.getPlayerScore(2) ? [1] : [2]; // draws not possible + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + return this; + } + + public state(): ILinageState { + return { + game: LinageGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + public moveState(): IMoveState { + return { + _version: LinageGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + komi: this.komi, + swapped: this.swapped + }; + } + + public getPlayerColour(player: playerid): number | string { + return (player == 1 && !this.swapped) || (player == 2 && this.swapped) ? 1 : 2; + } + + 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"), "_"); + + const pieceColour: Colourfuncs = { + func: "custom", + default: "#999", + palette: 3 + }; + + // Build rep + const rep: APRenderRep = { + options: ["hide-star-points"], + board: { + style: "vertex", + width: this.boardSize, + height: this.boardSize, + }, + legend: { + A: [{ name: "piece", colour: pieceColour }], + B: [{ name: "piece", colour: pieceColour }], + }, + pieces: pstr + }; + + // add territory dots + if (this.stack.length > 2) { + const territories = this.getTerritories(); + const markers: Array = [] + for (const t of territories) { + if (t.owner !== undefined) { + const points = t.cells.map(c => this.algebraic2coords(c)); + if (t.owner !== 3) { + markers.push({type: "dots", + colour: this.getPlayerColour(t.owner), + points: points.map(p => { return {col: p[0], row: p[1]}; }) as [RowCol, ...RowCol[]]}); + } + } + } + if (markers.length > 0) { + (rep.board as BoardBasic).markers = markers; + } + } + + // Add annotations + if (this.stack[this.stack.length - 1]._results.length > 0) { + rep.annotations = []; + for (const move of this.stack[this.stack.length - 1]._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 getButtons(): ICustomButton[] { + if (this.moves().includes("pass")) + return [{ label: "pass", move: "pass" }]; + if (this.moves().includes("play-second")) + return [{ label: "playsecond", move: "play-second" }]; + return []; // no buttons should appear when typing Komi at start + } + + public sidebarScores(): IScores[] { + return [ { name: i18next.t("apgames:status.SCORES"), + scores: [this.getPlayerScore(1), this.getPlayerScore(2)] } ]; + } + + public getPlayerScore(player: number): number { + let komi = 0.0; + if (player === 1 && this.komi !== undefined && this.komi < 0) + komi = -this.komi + 0.5; // 0.5 is to prevent draws + if (player === 2 && this.komi !== undefined && this.komi > 0) + komi = this.komi + 0.5; + + const terr = this.getTerritories(); + return terr.filter(t => t.owner === player).reduce((prev, curr) => prev + curr.cells.length, komi); + } + + public clone(): LinageGame { + return new LinageGame(this.serialize()); + } +}