From be2d268c3623e9b5271df9ccec88e1cc152cd5c9 Mon Sep 17 00:00:00 2001 From: mo-blo Date: Wed, 17 Jun 2026 20:05:50 +0300 Subject: [PATCH 1/3] Add Blood King Rises game file --- src/games/bloodking.ts | 694 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 694 insertions(+) create mode 100644 src/games/bloodking.ts diff --git a/src/games/bloodking.ts b/src/games/bloodking.ts new file mode 100644 index 00000000..b14c8e89 --- /dev/null +++ b/src/games/bloodking.ts @@ -0,0 +1,694 @@ +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 i18next from "i18next"; + +export type playerid = 1 | 2; +type Piece = "K" | "Q" | "R" | "B" | "N" | "P"; +type CellContents = `${playerid}${Piece}`; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; + captureCount: number; + bloodKingsRisen: boolean; +} + +export interface IBloodKingState extends IAPGameState { + winner: playerid[]; + stack: Array; +} +export class BloodKingGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Blood King Rises", + uid: "bloodking", + playercounts: [2], + version: "20260617", + description: "apgames:descriptions.bloodking", + notes: "apgames:notes.bloodking", + people: [ + { + type: "designer", + name: "Morgan B", + }, + ], + categories: [ + "goal>checkmate", + "mechanic>capture", + "mechanic>move", + "board>shape>rect", + "board>connect>rect", + "components>fairychess", + ], + flags: [], + }; + + public static coords2algebraic(x: number, y: number): string { + return GameBase.coords2algebraic(x, y, 8); + } + + public static algebraic2coords(cell: string): [number, number] { + return GameBase.algebraic2coords(cell, 8); + } + + 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 = []; + public captureCount = 0; + public bloodKingsRisen = false; + + constructor(state?: IBloodKingState | string, variants?: string[]) { + super(); + + if (state === undefined) { + const board = new Map([ + ["a1", "1R"], ["b1", "1N"], ["c1", "1B"], ["d1", "1Q"], + ["e1", "1K"], ["f1", "1B"], ["g1", "1N"], ["h1", "1R"], + ["a2", "1P"], ["b2", "1P"], ["c2", "1P"], ["d2", "1P"], + ["e2", "1P"], ["f2", "1P"], ["g2", "1P"], ["h2", "1P"], + + ["a8", "2R"], ["b8", "2N"], ["c8", "2B"], ["d8", "2Q"], + ["e8", "2K"], ["f8", "2B"], ["g8", "2N"], ["h8", "2R"], + ["a7", "2P"], ["b7", "2P"], ["c7", "2P"], ["d7", "2P"], + ["e7", "2P"], ["f7", "2P"], ["g7", "2P"], ["h7", "2P"], + ]); + + const fresh: IMoveState = { + _version: BloodKingGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + captureCount: 0, + bloodKingsRisen: false, + }; + + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as IBloodKingState; + } + if (state.game !== BloodKingGame.gameinfo.uid) { + throw new Error(`Blood King Rises cannot process a game of '${state.game}'.`); + } + this.gameover = state.gameover; + this.winner = [...state.winner]; + this.variants = state.variants; + this.stack = [...state.stack]; + } + + if (variants !== undefined) { + this.variants = [...variants]; + } + + this.load(); + } + + public load(idx = -1): BloodKingGame { + 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]; + this.captureCount = state.captureCount; + this.bloodKingsRisen = state.bloodKingsRisen; + return this; + } private owner(piece: CellContents): playerid { + return piece[0] === "1" ? 1 : 2; + } + + private kind(piece: CellContents): Piece { + return piece[1] as Piece; + } + + private otherPlayer(player: playerid): playerid { + return player === 1 ? 2 : 1; + } + + private isFriendly(cell: string, player: playerid): boolean { + const piece = this.board.get(cell); + return piece !== undefined && this.owner(piece) === player; + } + + private isEnemy(cell: string, player: playerid): boolean { + const piece = this.board.get(cell); + return piece !== undefined && this.owner(piece) !== player; + } + + private findKing(player: playerid): string { + for (const [cell, piece] of this.board.entries()) { + if (piece === `${player}K`) { + return cell; + } + } + throw new Error("Could not find king."); + } + + private cellOnBoard(cell: string): boolean { + try { + BloodKingGame.algebraic2coords(cell); + return true; + } catch { + return false; + } + } + private addIfLegalTarget(moves: string[], from: string, to: string, player: playerid): void { + if (!this.cellOnBoard(to)) { + return; + } + if (this.isFriendly(to, player)) { + return; + } + const sep = this.board.has(to) ? "x" : "-"; + moves.push(`${from}${sep}${to}`); + } + + private rayMoves(from: string, directions: Array<[number, number]>, player: playerid): string[] { + const moves: string[] = []; + const [x, y] = BloodKingGame.algebraic2coords(from); + + for (const [dx, dy] of directions) { + let nx = x + dx; + let ny = y + dy; + + while (nx >= 0 && nx < 8 && ny >= 0 && ny < 8) { + const to = BloodKingGame.coords2algebraic(nx, ny); + + if (this.isFriendly(to, player)) { + break; + } + + if (this.isEnemy(to, player)) { + moves.push(`${from}x${to}`); + break; + } + + moves.push(`${from}-${to}`); + nx += dx; + ny += dy; + } + } + + return moves; + } + private pieceMoves(from: string, player: playerid): string[] { + const piece = this.board.get(from); + if (piece === undefined) { + return []; + } + + if (this.owner(piece) !== player) { + return []; + } + + const kind = this.kind(piece); + const moves: string[] = []; + const [x, y] = BloodKingGame.algebraic2coords(from); + + if (kind === "P") { + const dir = player === 1 ? -1 : 1; + const startRank = player === 1 ? 6 : 1; + + const oneY = y + dir; + if (oneY >= 0 && oneY < 8) { + const one = BloodKingGame.coords2algebraic(x, oneY); + if (!this.board.has(one)) { + moves.push(`${from}-${one}`); + + const twoY = y + (dir * 2); + if (y === startRank && twoY >= 0 && twoY < 8) { + const two = BloodKingGame.coords2algebraic(x, twoY); + if (!this.board.has(two)) { + moves.push(`${from}-${two}`); + } + } + } + } + + for (const dx of [-1, 1]) { + const cx = x + dx; + const cy = y + dir; + if (cx >= 0 && cx < 8 && cy >= 0 && cy < 8) { + const target = BloodKingGame.coords2algebraic(cx, cy); + if (this.isEnemy(target, player)) { + moves.push(`${from}x${target}`); + } + } + } + } + + if (kind === "N") { + const jumps: Array<[number, number]> = [ + [1, 2], [2, 1], [2, -1], [1, -2], + [-1, -2], [-2, -1], [-2, 1], [-1, 2], + ]; + + for (const [dx, dy] of jumps) { + const target = BloodKingGame.coords2algebraic(x + dx, y + dy); + this.addIfLegalTarget(moves, from, target, player); + } + } + + if (kind === "B") { + return this.rayMoves(from, [[1, 1], [1, -1], [-1, 1], [-1, -1]], player); + } + + if (kind === "R") { + return this.rayMoves(from, [[1, 0], [-1, 0], [0, 1], [0, -1]], player); + } + + if (kind === "Q") { + return this.rayMoves(from, [ + [1, 0], [-1, 0], [0, 1], [0, -1], + [1, 1], [1, -1], [-1, 1], [-1, -1], + ], player); + } + + if (kind === "K") { + const steps: Array<[number, number]> = [ + [1, 0], [-1, 0], [0, 1], [0, -1], + [1, 1], [1, -1], [-1, 1], [-1, -1], + ]; + + for (const [dx, dy] of steps) { + const target = BloodKingGame.coords2algebraic(x + dx, y + dy); + this.addIfLegalTarget(moves, from, target, player); + } + + if (this.bloodKingsRisen) { + const jumps: Array<[number, number]> = [ + [1, 2], [2, 1], [2, -1], [1, -2], + [-1, -2], [-2, -1], [-2, 1], [-1, 2], + ]; + + for (const [dx, dy] of jumps) { + const target = BloodKingGame.coords2algebraic(x + dx, y + dy); + this.addIfLegalTarget(moves, from, target, player); + } + } + } + + return moves; + } + private attacksSquare(from: string, target: string, player: playerid): boolean { + const piece = this.board.get(from); + if (piece === undefined || this.owner(piece) !== player) { + return false; + } + + const kind = this.kind(piece); + const [fx, fy] = BloodKingGame.algebraic2coords(from); + const [tx, ty] = BloodKingGame.algebraic2coords(target); + const dx = tx - fx; + const dy = ty - fy; + + if (kind === "P") { + const dir = player === 1 ? -1 : 1; + return dy === dir && Math.abs(dx) === 1; + } + + if (kind === "N") { + return (Math.abs(dx) === 1 && Math.abs(dy) === 2) || + (Math.abs(dx) === 2 && Math.abs(dy) === 1); + } + + if (kind === "K") { + const kingAttack = Math.max(Math.abs(dx), Math.abs(dy)) === 1; + const bloodAttack = this.bloodKingsRisen && + ((Math.abs(dx) === 1 && Math.abs(dy) === 2) || + (Math.abs(dx) === 2 && Math.abs(dy) === 1)); + return kingAttack || bloodAttack; + } + + const clearLine = (): boolean => { + const stepX = dx === 0 ? 0 : dx / Math.abs(dx); + const stepY = dy === 0 ? 0 : dy / Math.abs(dy); + let x = fx + stepX; + let y = fy + stepY; + + while (x !== tx || y !== ty) { + const cell = BloodKingGame.coords2algebraic(x, y); + if (this.board.has(cell)) { + return false; + } + x += stepX; + y += stepY; + } + + return true; + }; + + if (kind === "B") { + return Math.abs(dx) === Math.abs(dy) && clearLine(); + } + + if (kind === "R") { + return (dx === 0 || dy === 0) && clearLine(); + } + + if (kind === "Q") { + return ((dx === 0 || dy === 0) || Math.abs(dx) === Math.abs(dy)) && clearLine(); + } + + return false; + } + + private inCheck(player: playerid): boolean { + const king = this.findKing(player); + const enemy = this.otherPlayer(player); + + for (const [cell, piece] of this.board.entries()) { + if (this.owner(piece) === enemy && this.attacksSquare(cell, king, enemy)) { + return true; + } + } + + return false; + } + private moveLeavesKingInCheck(move: string, player: playerid): boolean { + const match = move.match(/^([a-h][1-8])([-x])([a-h][1-8])$/); + if (match === null) { + return true; + } + + const from = match[1]; + const to = match[3]; + const movingPiece = this.board.get(from); + const capturedPiece = this.board.get(to); + + if (movingPiece === undefined) { + return true; + } + + this.board.delete(from); + this.board.set(to, movingPiece); + + const stillInCheck = this.inCheck(player); + + this.board.delete(to); + this.board.set(from, movingPiece); + if (capturedPiece !== undefined) { + this.board.set(to, capturedPiece); + } + + return stillInCheck; + } + + public moves(player?: playerid): string[] { + if (this.gameover) { + return []; + } + + if (player === undefined) { + player = this.currplayer; + } + + const allMoves: string[] = []; + + for (const [cell, piece] of this.board.entries()) { + if (this.owner(piece) === player) { + allMoves.push(...this.pieceMoves(cell, player)); + } + } + + return allMoves.filter(m => !this.moveLeavesKingInCheck(m, player)); + } + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const cell = BloodKingGame.coords2algebraic(col, row); + + let newmove = ""; + if (move.length === 0) { + const clicked = this.board.get(cell); + if (clicked === undefined || this.owner(clicked) !== this.currplayer) { + return { move: "", message: "" } as IClickResult; + } + newmove = cell; + } else { + const from = move; + if (from === cell) { + return { move: "", message: "" } as IClickResult; + } + + const sep = this.board.has(cell) ? "x" : "-"; + newmove = `${from}${sep}${cell}`; + } + + const result = this.validateMove(newmove) as IClickResult; + result.move = result.valid ? 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.canrender = true; + result.message = "Select one of your pieces."; + return result; + } + + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + + if (/^[a-h][1-8]$/.test(m)) { + const piece = this.board.get(m); + if (piece === undefined || this.owner(piece) !== this.currplayer) { + result.valid = false; + result.message = "That is not your piece."; + return result; + } + + const possible = this.moves().filter(mv => mv.startsWith(m)); + if (possible.length === 0) { + result.valid = false; + result.message = "That piece has no legal moves."; + return result; + } + + result.valid = true; + result.complete = -1; + result.canrender = true; + result.message = "Choose a destination."; + return result; + } + + if (!/^[a-h][1-8][-x][a-h][1-8]$/.test(m)) { + result.valid = false; + result.message = "Moves should look like e2-e4 or e4xd5."; + return result; + } + + if (!this.moves().includes(m)) { + result.valid = false; + result.message = "That move is not legal."; + return result; + } + + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + public move(m: string, { trusted = false } = {}): BloodKingGame { + 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); + } + } + + const match = m.match(/^([a-h][1-8])([-x])([a-h][1-8])$/); + if (match === null) { + throw new Error("Malformed move."); + } + + const from = match[1]; + const sep = match[2]; + const to = match[3]; + + const movingPiece = this.board.get(from); + const capturedPiece = this.board.get(to); + + if (movingPiece === undefined) { + throw new Error("No piece on source square."); + } + + this.results = []; + + this.board.delete(from); + this.board.set(to, movingPiece); + + this.results.push({ type: "move", from, to, what: movingPiece }); + + if (sep === "x" && capturedPiece !== undefined) { + this.captureCount++; + this.results.push({ type: "capture", where: to, what: capturedPiece }); + + if (this.captureCount >= 6) { + this.bloodKingsRisen = true; + } + } + + this.lastmove = m; + this.currplayer = this.otherPlayer(this.currplayer); + + this.checkEOG(); + this.saveState(); + return this; + } + protected checkEOG(): BloodKingGame { + const nextPlayer = this.currplayer; + + if (this.moves(nextPlayer).length === 0) { + this.gameover = true; + + if (this.inCheck(nextPlayer)) { + this.winner = [this.otherPlayer(nextPlayer)]; + } else { + this.winner = [1, 2]; + } + } + + if (this.gameover) { + this.results.push( + { type: "eog" }, + { type: "winners", players: [...this.winner] } + ); + } + + return this; + } + public state(): IBloodKingState { + return { + game: BloodKingGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + public moveState(): IMoveState { + return { + _version: BloodKingGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + captureCount: this.captureCount, + bloodKingsRisen: this.bloodKingsRisen, + }; + } + public render(): APRenderRep { + let pstr = ""; + + for (let row = 0; row < 8; row++) { + if (pstr.length > 0) { + pstr += "\n"; + } + + const pieces: string[] = []; + + for (let col = 0; col < 8; col++) { + const cell = BloodKingGame.coords2algebraic(col, row); + const piece = this.board.get(cell); + + if (piece === undefined) { + pieces.push("-"); + } else { + pieces.push(piece); + } + } + + pstr += pieces.join(","); + } + + pstr = pstr.replace(/-,-,-,-,-,-,-,-/g, "_"); + + const rep: APRenderRep = { + board: { + style: "squares-checkered", + width: 8, + height: 8, + }, + legend: { + "1K": { name: this.bloodKingsRisen ? "chess-king" : "chess-king", colour: 1 }, + "1Q": { name: "chess-queen", colour: 1 }, + "1R": { name: "chess-rook", colour: 1 }, + "1B": { name: "chess-bishop", colour: 1 }, + "1N": { name: "chess-knight", colour: 1 }, + "1P": { name: "chess-pawn", colour: 1 }, + + "2K": { name: this.bloodKingsRisen ? "chess-king" : "chess-king", colour: 2 }, + "2Q": { name: "chess-queen", colour: 2 }, + "2R": { name: "chess-rook", colour: 2 }, + "2B": { name: "chess-bishop", colour: 2 }, + "2N": { name: "chess-knight", colour: 2 }, + "2P": { name: "chess-pawn", colour: 2 }, + }, + pieces: pstr, + }; + + if (this.lastmove !== undefined) { + const match = this.lastmove.match(/^([a-h][1-8])[-x]([a-h][1-8])$/); + if (match !== null) { + const from = BloodKingGame.algebraic2coords(match[1]); + const to = BloodKingGame.algebraic2coords(match[2]); + rep.annotations = [ + { + type: "move", + targets: [ + { row: from[1], col: from[0] }, + { row: to[1], col: to[0] }, + ], + }, + ]; + } + } + + return rep; + } + + public clone(): BloodKingGame { + return new BloodKingGame(this.serialize()); + } +} \ No newline at end of file From 76faedb4a6c410be99114ec749efce6b1274b6e1 Mon Sep 17 00:00:00 2001 From: mo-blo Date: Thu, 18 Jun 2026 04:41:41 +0000 Subject: [PATCH 2/3] Add Blood King Rises --- locales/en/apgames.json | 2 + src/games/bloodking.ts | 199 ++++++++++++++++++++++++++++++++-------- src/games/index.ts | 8 +- 3 files changed, 168 insertions(+), 41 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 48900166..8e433347 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -29,6 +29,7 @@ "blam": "An Icehouse game for 2–4 players played on a standard chess board. Pieces placed push adjacent pieces away. Push pieces off the board to capture them. Whoever has captured the highest pip total of pieces at the end of the game wins.", "blastradius": "An abstract war game where pieces irradiate the spaces around them. Eliminate all opposing pieces.", "blockade": "Blockade (also known as Cul-de-sac) is a 'beat the barrier' game where you get one of your pawns to a starting space of your opponent. Players place 2-wide walls to block each other.", + "bloodking": "Blood King Rises is a chess variant where the sixth capture awakens both kings. Once risen, kings also move like knights, and capturing a king ends the game.", "blooms": "Each player owns 2 colours of stones. Players take turns placing one or two stones each turn (if you place two, they must be different colours), then capture all fenced enemy blooms. The first player to capture a certain number of stones wins.", "bloqueo": "Manipulate pawns to create towers of player pieces. Score points based on connected groups of controlled towers. Highest score wins.", "bluestone": "Connect your groups to the most blue pieces on the perimeter of the board, in this mixture of territory and connection game.", @@ -271,6 +272,7 @@ "ataxx": "On three-fold repetition, the game ends and the scores are calculated.", "bao": "Moves in Bao can be very complex, involving multiple laps around the board and changing directions. The annotations are, therefore, sparse. The initial cell and direction are highlighted, and captured cells are also marked. But detailed annotation of movement is not possible. If you believe you have encountered a bug, please let us know in Discord.", "biscuit": "Game ends when, at the end of a round, someone has reached or surpassed the target score. Highest score wins. Draws are possible.\n\nWhen calculating biscuits, a single card cannot be counted more than once. For example, if the root card is a 6, and the end of the main line is also a 6 (a cross card), and you play a 6 above that cross card, you score the \"hot cross biscuit\" (top and bottom of the cross line match) but you don't *also* get a regular \"biscuit\" for matching the end of the main line (they're both the same card). Furthermore, cross lines don't get counted at all unless there are at least two cards in the line. So playing a new cross card (in our example, a 6) does not automatically score you a \"biscuit\" (playing a card on the main line that matches a cross line).\n\nMore 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.", + "bloodking": "Prototype version. Current implementation supports normal piece movement, captures, Blood King rising after the sixth capture, and king capture as game end. Castling, en passant, promotion, and full draw rules are not yet implemented.", "blooms": "The threshold value is X(n) = 5n, where n is the base of the hexhex board (e.g., the default board is base 6, so the threshold is 30).", "btt": "Unusual numbers of players (3, 5, or 6) are supported, but on a normal rectangular board of an appropriate size rather than a Martian Chessboard.", "bug": "In this implementation, after the initial placement, if the player has bugs that must eat, the edible bugs will be highlighted. Eating and bonus grow will be performed simultaneously, so performing a bonus grow on a bug by selecting a legal space next to it will cause all highlighted edible bugs adjacent to it to disappear. It is possible to bonus grow onto a space that an edible bug is on. Note that this implies that when selecting a bug to perform the eating action, ALL edible bugs of the same shape next to it will be removed, and this might differ from some other implementations where there a separate edible bug selection phase, which may allow some edible bugs to be left behind for another bug to eat.", diff --git a/src/games/bloodking.ts b/src/games/bloodking.ts index b14c8e89..6946b8f5 100644 --- a/src/games/bloodking.ts +++ b/src/games/bloodking.ts @@ -2,7 +2,7 @@ import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResu 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 { reviver, UserFacingError } from "../common"; import i18next from "i18next"; export type playerid = 1 | 2; @@ -27,6 +27,7 @@ export class BloodKingGame extends GameBase { uid: "bloodking", playercounts: [2], version: "20260617", + dateAdded: "2026-06-17", description: "apgames:descriptions.bloodking", notes: "apgames:notes.bloodking", people: [ @@ -168,12 +169,19 @@ export class BloodKingGame extends GameBase { } } private addIfLegalTarget(moves: string[], from: string, to: string, player: playerid): void { - if (!this.cellOnBoard(to)) { - return; - } - if (this.isFriendly(to, player)) { + if (!this.cellOnBoard(to)) { + return; + } + + if (this.isFriendly(to, player)) { + return; + } + + const target = this.board.get(to); + if (target !== undefined && this.kind(target) === "K" && !this.bloodKingsRisen) { return; } + const sep = this.board.has(to) ? "x" : "-"; moves.push(`${from}${sep}${to}`); } @@ -259,8 +267,13 @@ export class BloodKingGame extends GameBase { ]; for (const [dx, dy] of jumps) { - const target = BloodKingGame.coords2algebraic(x + dx, y + dy); - this.addIfLegalTarget(moves, from, target, player); + const nx = x + dx; + const ny = y + dy; + + if (nx >= 0 && nx < 8 && ny >= 0 && ny < 8) { + const target = BloodKingGame.coords2algebraic(nx, ny); + this.addIfLegalTarget(moves, from, target, player); +} } } @@ -286,8 +299,13 @@ export class BloodKingGame extends GameBase { ]; for (const [dx, dy] of steps) { - const target = BloodKingGame.coords2algebraic(x + dx, y + dy); - this.addIfLegalTarget(moves, from, target, player); + const nx = x + dx; + const ny = y + dy; + + if (nx >= 0 && nx < 8 && ny >= 0 && ny < 8) { + const target = BloodKingGame.coords2algebraic(nx, ny); + this.addIfLegalTarget(moves, from, target, player); +} } if (this.bloodKingsRisen) { @@ -297,8 +315,13 @@ export class BloodKingGame extends GameBase { ]; for (const [dx, dy] of jumps) { - const target = BloodKingGame.coords2algebraic(x + dx, y + dy); - this.addIfLegalTarget(moves, from, target, player); + const nx = x + dx; + const ny = y + dy; + + if (nx >= 0 && nx < 8 && ny >= 0 && ny < 8) { + const target = BloodKingGame.coords2algebraic(nx, ny); + this.addIfLegalTarget(moves, from, target, player); +} } } } @@ -557,10 +580,21 @@ export class BloodKingGame extends GameBase { this.results.push({ type: "move", from, to, what: movingPiece }); - if (sep === "x" && capturedPiece !== undefined) { + if (sep === "x" && capturedPiece !== undefined) { this.captureCount++; this.results.push({ type: "capture", where: to, what: capturedPiece }); + if (capturedPiece[1] === "K") { + this.gameover = true; + this.winner = [this.owner(movingPiece)]; + this.results.push( + { type: "eog" }, + { type: "winners", players: [...this.winner] } + ); + this.saveState(); + return this; + } + if (this.captureCount >= 6) { this.bloodKingsRisen = true; } @@ -618,54 +652,142 @@ export class BloodKingGame extends GameBase { bloodKingsRisen: this.bloodKingsRisen, }; } - public render(): APRenderRep { - let pstr = ""; + public render(): APRenderRep { + const codeForPiece = (piece: CellContents): string => { + const owner = this.owner(piece); + const kind = this.kind(piece); + + if (owner === 1 && kind === "K") { return "WK"; } + if (owner === 1 && kind === "Q") { return "WQ"; } + if (owner === 1 && kind === "R") { return "WR"; } + if (owner === 1 && kind === "B") { return "WB"; } + if (owner === 1 && kind === "N") { return "WN"; } + if (owner === 1 && kind === "P") { return "WP"; } + + if (owner === 2 && kind === "K") { return "BK"; } + if (owner === 2 && kind === "Q") { return "BQ"; } + if (owner === 2 && kind === "R") { return "BR"; } + if (owner === 2 && kind === "B") { return "BB"; } + if (owner === 2 && kind === "N") { return "BN"; } + return "BP"; + }; - for (let row = 0; row < 8; row++) { - if (pstr.length > 0) { - pstr += "\n"; - } + const pieces: string[][][] = []; - const pieces: string[] = []; + for (let row = 0; row < 8; row++) { + const renderedRow: string[][] = []; for (let col = 0; col < 8; col++) { const cell = BloodKingGame.coords2algebraic(col, row); const piece = this.board.get(cell); if (piece === undefined) { - pieces.push("-"); + renderedRow.push([]); } else { - pieces.push(piece); + renderedRow.push([codeForPiece(piece)]); } } - pstr += pieces.join(","); + pieces.push(renderedRow); } - pstr = pstr.replace(/-,-,-,-,-,-,-,-/g, "_"); - const rep: APRenderRep = { + renderer: "stacking-offset", board: { style: "squares-checkered", width: 8, height: 8, + rotate: 0, }, legend: { - "1K": { name: this.bloodKingsRisen ? "chess-king" : "chess-king", colour: 1 }, - "1Q": { name: "chess-queen", colour: 1 }, - "1R": { name: "chess-rook", colour: 1 }, - "1B": { name: "chess-bishop", colour: 1 }, - "1N": { name: "chess-knight", colour: 1 }, - "1P": { name: "chess-pawn", colour: 1 }, - - "2K": { name: this.bloodKingsRisen ? "chess-king" : "chess-king", colour: 2 }, - "2Q": { name: "chess-queen", colour: 2 }, - "2R": { name: "chess-rook", colour: 2 }, - "2B": { name: "chess-bishop", colour: 2 }, - "2N": { name: "chess-knight", colour: 2 }, - "2P": { name: "chess-pawn", colour: 2 }, + "WK": { + name: "chess-king-outline-traditional", + scale: this.bloodKingsRisen ? 1.2 : 1, + opacity: 1, + rotate: this.bloodKingsRisen ? 180 : 0, + colour: 1, + }, + "WQ": { + name: "chess-queen-outline-traditional", + scale: 1, + opacity: 1, + rotate: 0, + colour: 1, + }, + "WR": { + name: "chess-rook-outline-traditional", + scale: 1, + opacity: 1, + rotate: 0, + colour: 1, + }, + "WB": { + name: "chess-bishop-solid-traditional", + scale: 1, + opacity: 1, + rotate: 0, + colour: 1, + }, + "WN": { + name: "chess-knight-outline-traditional", + scale: 1, + opacity: 1, + rotate: 0, + colour: 1, + }, + "WP": { + name: "chess-pawn-outline-traditional", + scale: 1, + opacity: 1, + rotate: 0, + colour: 1, + }, + + "BK": { + name: "chess-king-outline-traditional", + scale: this.bloodKingsRisen ? 1.2 : 1, + opacity: 1, + rotate: this.bloodKingsRisen ? 180 : 0, + colour: 2, + }, + "BQ": { + name: "chess-queen-outline-traditional", + scale: 1, + opacity: 1, + rotate: 0, + colour: 2, + }, + "BR": { + name: "chess-rook-outline-traditional", + scale: 1, + opacity: 1, + rotate: 0, + colour: 2, + }, + "BB": { + name: "chess-bishop-solid-traditional", + scale: 1, + opacity: 1, + rotate: 0, + colour: 2, + }, + "BN": { + name: "chess-knight-outline-traditional", + scale: 1, + opacity: 1, + rotate: 0, + colour: 2, + }, + "BP": { + name: "chess-pawn-outline-traditional", + scale: 1, + opacity: 1, + rotate: 0, + colour: 2, + }, }, - pieces: pstr, + pieces: pieces as [string[][], ...string[][][]], + annotations: [], }; if (this.lastmove !== undefined) { @@ -687,7 +809,6 @@ export class BloodKingGame extends GameBase { return rep; } - public clone(): BloodKingGame { return new BloodKingGame(this.serialize()); } diff --git a/src/games/index.ts b/src/games/index.ts index 992027be..35631237 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -4,6 +4,7 @@ import { AmazonsGame, IAmazonsState } from "./amazons"; import { BlamGame, IBlamState } from "./blam"; import { CannonGame, ICannonState } from "./cannon"; import { MchessGame, IMchessState } from "./mchess"; +import { BloodKingGame, IBloodKingState } from "./bloodking"; import { HomeworldsGame, IHomeworldsState } from "./homeworlds"; import { EntropyGame, IEntropyState } from "./entropy"; import { VolcanoGame, IVolcanoState } from "./volcano"; @@ -268,6 +269,7 @@ export { BlamGame, IBlamState, CannonGame, ICannonState, MchessGame, IMchessState, + BloodKingGame, IBloodKingState, HomeworldsGame, IHomeworldsState, EntropyGame, IEntropyState, VolcanoGame, IVolcanoState, @@ -528,7 +530,7 @@ export { }; const games = new Map(); // Manually add each game to the following array [ - AmazonsGame, BlamGame, CannonGame, MchessGame, HomeworldsGame, EntropyGame, + AmazonsGame, BlamGame, CannonGame, MchessGame, BloodKingGame, HomeworldsGame, EntropyGame, VolcanoGame, MvolcanoGame, ChaseGame, AbandeGame, CephalopodGame, LinesOfActionGame, PikemenGame, OrdoGame, AttangleGame, AccastaGame, EpamGame, TaijiGame, BreakthroughGame, FabrikGame, ManalathGame, UrbinoGame, FendoGame, ArchimedesGame, ZolaGame, MonkeyQueenGame, @@ -675,6 +677,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new CannonGame(...args); case "mchess": return new MchessGame(...args); + case "bloodking": + return new BloodKingGame(...args); case "homeworlds": return new HomeworldsGame(args[0], ...args.slice(1)); case "entropy": From 1d8c08a690be2f2b020a0b8403f463a6cb31027b Mon Sep 17 00:00:00 2001 From: mo-blo Date: Thu, 18 Jun 2026 18:30:08 +0000 Subject: [PATCH 3/3] Add pawn promotion to Blood King Rises --- src/games/bloodking.ts | 226 ++++++++++++++++++++++++++++++----------- 1 file changed, 166 insertions(+), 60 deletions(-) diff --git a/src/games/bloodking.ts b/src/games/bloodking.ts index 6946b8f5..e75fd533 100644 --- a/src/games/bloodking.ts +++ b/src/games/bloodking.ts @@ -7,8 +7,16 @@ import i18next from "i18next"; export type playerid = 1 | 2; type Piece = "K" | "Q" | "R" | "B" | "N" | "P"; +type PromotionPiece = "Q" | "R" | "B" | "N"; type CellContents = `${playerid}${Piece}`; +type ParsedMove = { + from: string; + sep: "-" | "x"; + to: string; + promotion?: PromotionPiece; +}; + export interface IMoveState extends IIndividualState { currplayer: playerid; board: Map; @@ -21,12 +29,13 @@ export interface IBloodKingState extends IAPGameState { winner: playerid[]; stack: Array; } + export class BloodKingGame extends GameBase { public static readonly gameinfo: APGamesInformation = { name: "Blood King Rises", uid: "bloodking", playercounts: [2], - version: "20260617", + version: "20260618", dateAdded: "2026-06-17", description: "apgames:descriptions.bloodking", notes: "apgames:notes.bloodking", @@ -47,6 +56,8 @@ export class BloodKingGame extends GameBase { flags: [], }; + private static readonly promotionPieces: PromotionPiece[] = ["Q", "R", "B", "N"]; + public static coords2algebraic(x: number, y: number): string { return GameBase.coords2algebraic(x, y, 8); } @@ -129,7 +140,9 @@ export class BloodKingGame extends GameBase { this.captureCount = state.captureCount; this.bloodKingsRisen = state.bloodKingsRisen; return this; - } private owner(piece: CellContents): playerid { + } + + private owner(piece: CellContents): playerid { return piece[0] === "1" ? 1 : 2; } @@ -168,14 +181,48 @@ export class BloodKingGame extends GameBase { return false; } } - private addIfLegalTarget(moves: string[], from: string, to: string, player: playerid): void { - if (!this.cellOnBoard(to)) { - return; - } - if (this.isFriendly(to, player)) { - return; + private parseMove(m: string): ParsedMove | undefined { + const match = m.match(/^([a-h][1-8])([-x])([a-h][1-8])(?:=([qrbn]))?$/); + if (match === null) { + return undefined; + } + + return { + from: match[1], + sep: match[2] as "-" | "x", + to: match[3], + promotion: match[4] === undefined ? undefined : match[4].toUpperCase() as PromotionPiece, + }; + } + + private promotionRank(player: playerid): number { + return player === 1 ? 0 : 7; + } + + private isPromotionSquare(cell: string, player: playerid): boolean { + const [, y] = BloodKingGame.algebraic2coords(cell); + return y === this.promotionRank(player); + } + + private addPromotionMoves(moves: string[], baseMove: string, to: string, player: playerid): void { + if (this.isPromotionSquare(to, player)) { + for (const promotion of BloodKingGame.promotionPieces) { + moves.push(`${baseMove}=${promotion.toLowerCase()}`); } + } else { + moves.push(baseMove); + } + } + + private addIfLegalTarget(moves: string[], from: string, to: string, player: playerid): void { + if (!this.cellOnBoard(to)) { + return; + } + + if (this.isFriendly(to, player)) { + return; + } const target = this.board.get(to); if (target !== undefined && this.kind(target) === "K" && !this.bloodKingsRisen) { @@ -202,7 +249,10 @@ export class BloodKingGame extends GameBase { } if (this.isEnemy(to, player)) { - moves.push(`${from}x${to}`); + const target = this.board.get(to); + if (target === undefined || this.kind(target) !== "K" || this.bloodKingsRisen) { + moves.push(`${from}x${to}`); + } break; } @@ -214,7 +264,8 @@ export class BloodKingGame extends GameBase { return moves; } - private pieceMoves(from: string, player: playerid): string[] { + + private pieceMoves(from: string, player: playerid): string[] { const piece = this.board.get(from); if (piece === undefined) { return []; @@ -236,7 +287,7 @@ export class BloodKingGame extends GameBase { if (oneY >= 0 && oneY < 8) { const one = BloodKingGame.coords2algebraic(x, oneY); if (!this.board.has(one)) { - moves.push(`${from}-${one}`); + this.addPromotionMoves(moves, `${from}-${one}`, one, player); const twoY = y + (dir * 2); if (y === startRank && twoY >= 0 && twoY < 8) { @@ -253,8 +304,12 @@ export class BloodKingGame extends GameBase { const cy = y + dir; if (cx >= 0 && cx < 8 && cy >= 0 && cy < 8) { const target = BloodKingGame.coords2algebraic(cx, cy); - if (this.isEnemy(target, player)) { - moves.push(`${from}x${target}`); + const targetPiece = this.board.get(target); + if (targetPiece !== undefined && this.owner(targetPiece) !== player) { + if (this.kind(targetPiece) === "K" && !this.bloodKingsRisen) { + continue; + } + this.addPromotionMoves(moves, `${from}x${target}`, target, player); } } } @@ -273,7 +328,7 @@ export class BloodKingGame extends GameBase { if (nx >= 0 && nx < 8 && ny >= 0 && ny < 8) { const target = BloodKingGame.coords2algebraic(nx, ny); this.addIfLegalTarget(moves, from, target, player); -} + } } } @@ -305,7 +360,7 @@ export class BloodKingGame extends GameBase { if (nx >= 0 && nx < 8 && ny >= 0 && ny < 8) { const target = BloodKingGame.coords2algebraic(nx, ny); this.addIfLegalTarget(moves, from, target, player); -} + } } if (this.bloodKingsRisen) { @@ -321,13 +376,14 @@ export class BloodKingGame extends GameBase { if (nx >= 0 && nx < 8 && ny >= 0 && ny < 8) { const target = BloodKingGame.coords2algebraic(nx, ny); this.addIfLegalTarget(moves, from, target, player); -} + } } } } return moves; } + private attacksSquare(from: string, target: string, player: playerid): boolean { const piece = this.board.get(from); if (piece === undefined || this.owner(piece) !== player) { @@ -403,31 +459,40 @@ export class BloodKingGame extends GameBase { return false; } + private moveLeavesKingInCheck(move: string, player: playerid): boolean { - const match = move.match(/^([a-h][1-8])([-x])([a-h][1-8])$/); - if (match === null) { + const parsed = this.parseMove(move); + if (parsed === undefined) { return true; } - const from = match[1]; - const to = match[3]; - const movingPiece = this.board.get(from); - const capturedPiece = this.board.get(to); + const movingPiece = this.board.get(parsed.from); + const capturedPiece = this.board.get(parsed.to); if (movingPiece === undefined) { return true; } - this.board.delete(from); - this.board.set(to, movingPiece); + const finalPiece = parsed.promotion === undefined + ? movingPiece + : `${this.owner(movingPiece)}${parsed.promotion}` as CellContents; + + const originalBloodKingsRisen = this.bloodKingsRisen; + if (capturedPiece !== undefined && this.captureCount + 1 >= 6) { + this.bloodKingsRisen = true; + } + + this.board.delete(parsed.from); + this.board.set(parsed.to, finalPiece); const stillInCheck = this.inCheck(player); - this.board.delete(to); - this.board.set(from, movingPiece); + this.board.delete(parsed.to); + this.board.set(parsed.from, movingPiece); if (capturedPiece !== undefined) { - this.board.set(to, capturedPiece); + this.board.set(parsed.to, capturedPiece); } + this.bloodKingsRisen = originalBloodKingsRisen; return stillInCheck; } @@ -451,6 +516,7 @@ export class BloodKingGame extends GameBase { return allMoves.filter(m => !this.moveLeavesKingInCheck(m, player)); } + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { try { const cell = BloodKingGame.coords2algebraic(col, row); @@ -470,6 +536,11 @@ export class BloodKingGame extends GameBase { const sep = this.board.has(cell) ? "x" : "-"; newmove = `${from}${sep}${cell}`; + + const movingPiece = this.board.get(from); + if (movingPiece !== undefined && this.kind(movingPiece) === "P" && this.isPromotionSquare(cell, this.currplayer)) { + newmove = `${newmove}=q`; + } } const result = this.validateMove(newmove) as IClickResult; @@ -480,8 +551,8 @@ export class BloodKingGame extends GameBase { move, valid: false, message: i18next.t("apgames:validation._general.GENERIC", { - move, row, col, piece, emessage: (e as Error).message - }) + move, row, col, piece, emessage: (e as Error).message, + }), }; } } @@ -489,7 +560,7 @@ export class BloodKingGame extends GameBase { public validateMove(m: string): IValidationResult { const result: IValidationResult = { valid: false, - message: i18next.t("apgames:validation._general.DEFAULT_HANDLER") + message: i18next.t("apgames:validation._general.DEFAULT_HANDLER"), }; if (m.length === 0) { @@ -525,12 +596,29 @@ export class BloodKingGame extends GameBase { return result; } - if (!/^[a-h][1-8][-x][a-h][1-8]$/.test(m)) { + const parsed = this.parseMove(m); + if (parsed === undefined) { result.valid = false; - result.message = "Moves should look like e2-e4 or e4xd5."; + result.message = "Moves should look like e2-e4, e4xd5, or e7-e8=q."; return result; } + const movingPiece = this.board.get(parsed.from); + if (movingPiece !== undefined && this.owner(movingPiece) === this.currplayer) { + const promotionMove = this.kind(movingPiece) === "P" && this.isPromotionSquare(parsed.to, this.currplayer); + if (promotionMove && parsed.promotion === undefined) { + result.valid = false; + result.message = "Pawn promotions must include =q, =r, =b, or =n."; + return result; + } + + if (parsed.promotion !== undefined && !promotionMove) { + result.valid = false; + result.message = "Only a pawn moving to the final rank can promote."; + return result; + } + } + if (!this.moves().includes(m)) { result.valid = false; result.message = "That move is not legal."; @@ -542,6 +630,7 @@ export class BloodKingGame extends GameBase { result.message = i18next.t("apgames:validation._general.VALID_MOVE"); return result; } + public move(m: string, { trusted = false } = {}): BloodKingGame { if (this.gameover) { throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); @@ -557,56 +646,70 @@ export class BloodKingGame extends GameBase { } } - const match = m.match(/^([a-h][1-8])([-x])([a-h][1-8])$/); - if (match === null) { + const parsed = this.parseMove(m); + if (parsed === undefined) { throw new Error("Malformed move."); } - const from = match[1]; - const sep = match[2]; - const to = match[3]; - - const movingPiece = this.board.get(from); - const capturedPiece = this.board.get(to); + const movingPiece = this.board.get(parsed.from); + const capturedPiece = this.board.get(parsed.to); if (movingPiece === undefined) { throw new Error("No piece on source square."); } + const promotedPiece = parsed.promotion === undefined + ? undefined + : `${this.owner(movingPiece)}${parsed.promotion}` as CellContents; + const finalPiece = promotedPiece === undefined ? movingPiece : promotedPiece; + this.results = []; - this.board.delete(from); - this.board.set(to, movingPiece); + this.board.delete(parsed.from); + this.board.set(parsed.to, finalPiece); - this.results.push({ type: "move", from, to, what: movingPiece }); + this.results.push({ type: "move", from: parsed.from, to: parsed.to, what: movingPiece }); + this.lastmove = m; - if (sep === "x" && capturedPiece !== undefined) { + const capturedKing = capturedPiece !== undefined && this.kind(capturedPiece) === "K"; + + if (parsed.sep === "x" && capturedPiece !== undefined) { this.captureCount++; - this.results.push({ type: "capture", where: to, what: capturedPiece }); - - if (capturedPiece[1] === "K") { - this.gameover = true; - this.winner = [this.owner(movingPiece)]; - this.results.push( - { type: "eog" }, - { type: "winners", players: [...this.winner] } - ); - this.saveState(); - return this; - } + this.results.push({ type: "capture", where: parsed.to, what: capturedPiece }); if (this.captureCount >= 6) { this.bloodKingsRisen = true; } } - this.lastmove = m; + if (promotedPiece !== undefined) { + this.results.push({ + type: "promote", + player: this.owner(movingPiece), + from: movingPiece, + to: promotedPiece, + where: parsed.to, + }); + } + + if (capturedKing) { + this.gameover = true; + this.winner = [this.owner(movingPiece)]; + this.results.push( + { type: "eog" }, + { type: "winners", players: [...this.winner] }, + ); + this.saveState(); + return this; + } + this.currplayer = this.otherPlayer(this.currplayer); this.checkEOG(); this.saveState(); return this; } + protected checkEOG(): BloodKingGame { const nextPlayer = this.currplayer; @@ -623,12 +726,13 @@ export class BloodKingGame extends GameBase { if (this.gameover) { this.results.push( { type: "eog" }, - { type: "winners", players: [...this.winner] } + { type: "winners", players: [...this.winner] }, ); } return this; } + public state(): IBloodKingState { return { game: BloodKingGame.gameinfo.uid, @@ -652,7 +756,8 @@ export class BloodKingGame extends GameBase { bloodKingsRisen: this.bloodKingsRisen, }; } - public render(): APRenderRep { + + public render(): APRenderRep { const codeForPiece = (piece: CellContents): string => { const owner = this.owner(piece); const kind = this.kind(piece); @@ -791,7 +896,7 @@ export class BloodKingGame extends GameBase { }; if (this.lastmove !== undefined) { - const match = this.lastmove.match(/^([a-h][1-8])[-x]([a-h][1-8])$/); + const match = this.lastmove.match(/^([a-h][1-8])[-x]([a-h][1-8])(?:=[qrbn])?$/); if (match !== null) { const from = BloodKingGame.algebraic2coords(match[1]); const to = BloodKingGame.algebraic2coords(match[2]); @@ -809,6 +914,7 @@ export class BloodKingGame extends GameBase { return rep; } + public clone(): BloodKingGame { return new BloodKingGame(this.serialize()); }