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 new file mode 100644 index 00000000..e75fd533 --- /dev/null +++ b/src/games/bloodking.ts @@ -0,0 +1,921 @@ +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 { reviver, UserFacingError } from "../common"; +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; + 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: "20260618", + dateAdded: "2026-06-17", + 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: [], + }; + + private static readonly promotionPieces: PromotionPiece[] = ["Q", "R", "B", "N"]; + + 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 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) { + 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)) { + const target = this.board.get(to); + if (target === undefined || this.kind(target) !== "K" || this.bloodKingsRisen) { + 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)) { + this.addPromotionMoves(moves, `${from}-${one}`, one, player); + + 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); + 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); + } + } + } + } + + 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 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 (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 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) { + 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 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); + } + } + } + } + + 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 parsed = this.parseMove(move); + if (parsed === undefined) { + return true; + } + + const movingPiece = this.board.get(parsed.from); + const capturedPiece = this.board.get(parsed.to); + + if (movingPiece === undefined) { + return true; + } + + 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(parsed.to); + this.board.set(parsed.from, movingPiece); + if (capturedPiece !== undefined) { + this.board.set(parsed.to, capturedPiece); + } + this.bloodKingsRisen = originalBloodKingsRisen; + + 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 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; + 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; + } + + const parsed = this.parseMove(m); + if (parsed === undefined) { + result.valid = false; + 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."; + 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 parsed = this.parseMove(m); + if (parsed === undefined) { + throw new Error("Malformed move."); + } + + 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(parsed.from); + this.board.set(parsed.to, finalPiece); + + this.results.push({ type: "move", from: parsed.from, to: parsed.to, what: movingPiece }); + this.lastmove = m; + + const capturedKing = capturedPiece !== undefined && this.kind(capturedPiece) === "K"; + + if (parsed.sep === "x" && capturedPiece !== undefined) { + this.captureCount++; + this.results.push({ type: "capture", where: parsed.to, what: capturedPiece }); + + if (this.captureCount >= 6) { + this.bloodKingsRisen = true; + } + } + + 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; + + 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 { + 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"; + }; + + 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) { + renderedRow.push([]); + } else { + renderedRow.push([codeForPiece(piece)]); + } + } + + pieces.push(renderedRow); + } + + const rep: APRenderRep = { + renderer: "stacking-offset", + board: { + style: "squares-checkered", + width: 8, + height: 8, + rotate: 0, + }, + legend: { + "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: pieces as [string[][], ...string[][][]], + annotations: [], + }; + + if (this.lastmove !== undefined) { + 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]); + 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 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":