diff --git a/.changeset/rules-update-review.md b/.changeset/rules-update-review.md new file mode 100644 index 0000000..2ac26b8 --- /dev/null +++ b/.changeset/rules-update-review.md @@ -0,0 +1,8 @@ +--- +"gungi.js": patch +--- + +Restrict leap-over behavior for jumping pieces to forward movement only. + +- Cannon, archer, and musketeer can no longer jump over blockers when moving sideways or backward. +- Added movement regression tests that verify forward leaps remain legal and non-forward leaps are blocked. diff --git a/src/gungi/move_gen.ts b/src/gungi/move_gen.ts index 26799c7..127053d 100644 --- a/src/gungi/move_gen.ts +++ b/src/gungi/move_gen.ts @@ -1,5 +1,5 @@ -import type { Board, Color, HandPiece, Move, Piece, PieceType } from './utils'; -import { encodeFEN, parseFEN } from './fen'; +import { encodeFEN, parseFEN } from "./fen"; +import type { Board, Color, HandPiece, Move, Piece, PieceType } from "./utils"; import { convert, get, @@ -10,7 +10,7 @@ import { removeTop, SQUARES, updateHand, -} from './utils'; +} from "./utils"; export const dirs = [ [-1, 1], @@ -45,7 +45,7 @@ function getAvailableSquares( start: [number, number], origin: [number, number], length: number, - board: (Piece | null)[][][] + board: (Piece | null)[][][], ) { const [y, x] = start; const [py, px] = origin; @@ -75,7 +75,7 @@ function getAvailableSquares( return []; } - const side = originPiece.color === 'b' ? -1 : 1; + const side = originPiece.color === "b" ? -1 : 1; const below = getTop(`${reverse.y + side}-${reverse.x}`, board); if (below && below.square === `${py}-${px}`) break; @@ -97,7 +97,12 @@ function getAvailableSquares( pieceType.musketeer, pieceType.archer, ]; - if (piece && !leapPieces.includes(originPiece.type)) break; + if (piece) { + if (!leapPieces.includes(originPiece.type)) break; + + const movingForward = originPiece.color === "w" ? dy < 0 : dy > 0; + if (!movingForward) break; + } forward.x += dx; forward.y += dy; @@ -112,16 +117,16 @@ export function generateMovesForSquare(square: string, fen: string) { const piece = getTop(square, board); if (!piece || turn !== piece.color) return []; - const [py, px] = square.split('-').map(Number); + const [py, px] = square.split("-").map(Number); const probes = pieceProbes[piece.type]; const squares = probes.flatMap((probe, i) => { - const pval = typeof probe === 'number' ? probe : probe[0]; - const pcarry = typeof probe === 'number' ? 1 : probe[1]; + const pval = typeof probe === "number" ? probe : probe[0]; + const pcarry = typeof probe === "number" ? 1 : probe[1]; if (pval < 1) return []; let [dy, dx] = dirs[i]; - if (piece.color === 'b') { + if (piece.color === "b") { dy *= -1; dx *= -1; } @@ -137,16 +142,16 @@ export function generateMovesForSquare(square: string, fen: string) { const p = getTop(s, board); const t = get(s, board); - const maxTier = mode === 'advanced' ? 3 : 2; - const marshalCanStack = ['advanced', 'intermediate'].includes(mode); + const maxTier = mode === "advanced" ? 3 : 2; + const marshalCanStack = ["advanced", "intermediate"].includes(mode); if (!p || !t) { - acc.push(createMove(piece, `${s}-1`, fen, 'route')); + acc.push(createMove(piece, `${s}-1`, fen, "route")); } else { // tsuke if (p.tier < maxTier && p.type !== pieceType.marshal) { if (piece.type !== pieceType.marshal || marshalCanStack) { - acc.push(createMove(piece, `${s}-${p.tier + 1}`, fen, 'tsuke')); + acc.push(createMove(piece, `${s}-${p.tier + 1}`, fen, "tsuke")); } // betrayal @@ -160,15 +165,15 @@ export function generateMovesForSquare(square: string, fen: string) { const betrayalOptions = Array.from(enemyCountMap.entries()) .filter(([type, count]) => - playerHand.some((p) => p.type === type && p.count >= count) + playerHand.some((p) => p.type === type && p.count >= count), ) .flatMap(([type]) => enemies.filter((e) => e.type === type)); const combos = generateCombinations(betrayalOptions); acc.push( ...combos.map((combo) => - createMove(piece, `${s}-${p.tier + 1}`, fen, 'betray', combo) - ) + createMove(piece, `${s}-${p.tier + 1}`, fen, "betray", combo), + ), ); } } @@ -178,7 +183,7 @@ export function generateMovesForSquare(square: string, fen: string) { const newTier = t.filter((p) => p.color === piece.color).length + 1; const captured = t.filter((p) => p.color !== piece.color); acc.push( - createMove(piece, `${s}-${newTier}`, fen, 'capture', captured) + createMove(piece, `${s}-${newTier}`, fen, "capture", captured), ); } } @@ -191,29 +196,29 @@ export function generateArata(piece: HandPiece, fen: string) { const { board, turn, mode, hand, drafting } = parseFEN(fen); if (!piece || turn !== piece.color) return []; const isMarshalPlaced = !hand.some( - (p) => p.type === pieceType.marshal && p.color === piece.color + (p) => p.type === pieceType.marshal && p.color === piece.color, ); if (!isMarshalPlaced && piece.type !== pieceType.marshal) return []; const isDraft = drafting.b || drafting.w; - const maxTier = mode === 'advanced' ? 3 : 2; + const maxTier = mode === "advanced" ? 3 : 2; let ranks: number[] = []; let maybe: number[] = []; // if in draft can only place within your first 3 ranks, otherwise can placed as far as your deepest piece if (isDraft) { - ranks = piece.color === 'w' ? [7, 8, 9] : [1, 2, 3]; + ranks = piece.color === "w" ? [7, 8, 9] : [1, 2, 3]; } else { - const start = piece.color === 'b' ? 1 : 9; - const end = piece.color === 'b' ? 9 : 1; - const step = piece.color === 'b' ? 1 : -1; + const start = piece.color === "b" ? 1 : 9; + const end = piece.color === "b" ? 9 : 1; + const step = piece.color === "b" ? 1 : -1; for ( let rank = start; - piece.color === 'b' ? rank <= end : rank >= end; + piece.color === "b" ? rank <= end : rank >= end; rank += step ) { const tops = Array.from({ length: 9 }).map((_, i) => - getTop(`${rank}-${i + 1}`, board) + getTop(`${rank}-${i + 1}`, board), ); if (tops.some((p) => p && p.color === piece.color)) { ranks.push(...maybe, rank); @@ -225,7 +230,7 @@ export function generateArata(piece: HandPiece, fen: string) { } const squares = ranks.flatMap((rank) => - Array.from({ length: 9 }, (_, i) => `${rank}-${i + 1}`) + Array.from({ length: 9 }, (_, i) => `${rank}-${i + 1}`), ); return squares.reduce((acc, s) => { @@ -253,11 +258,11 @@ export function generateArata(piece: HandPiece, fen: string) { if (drafting[piece.color]) { // If only 1 piece left, must end draft - no option to continue if (!isLastPiece) { - acc.push(createMove(arata, `${s}-${t}`, fen, 'arata')); + acc.push(createMove(arata, `${s}-${t}`, fen, "arata")); } - acc.push(createMove(arata, `${s}-${t}`, fen, 'arata', [], true)); + acc.push(createMove(arata, `${s}-${t}`, fen, "arata", [], true)); } else { - acc.push(createMove(arata, `${s}-${t}`, fen, 'arata')); + acc.push(createMove(arata, `${s}-${t}`, fen, "arata")); } } @@ -269,19 +274,19 @@ function createMove( piece: Piece, to: string, fen: string, - type: Move['type'], + type: Move["type"], captured?: Piece[], - draftFinished?: boolean + draftFinished?: boolean, ) { - const from = piece.tier !== 0 ? `(${piece.square}-${piece.tier})` : ''; - const toTier = +(to.split('-').at(-1) ?? 0); - const arata = type === 'arata' ? '新' : ''; - const capture = type === 'capture' ? '取' : ''; + const from = piece.tier !== 0 ? `(${piece.square}-${piece.tier})` : ""; + const toTier = +(to.split("-").at(-1) ?? 0); + const arata = type === "arata" ? "新" : ""; + const capture = type === "capture" ? "取" : ""; const tsuke = - type === 'tsuke' || (toTier !== 1 && toTier - piece.tier > 0) ? '付' : ''; + type === "tsuke" || (toTier !== 1 && toTier - piece.tier > 0) ? "付" : ""; const betray = - type === 'betray' ? `返${captured?.map((p) => p.type).join('')}` : ''; - const draftDone = draftFinished ? '終' : ''; + type === "betray" ? `返${captured?.map((p) => p.type).join("")}` : ""; + const draftDone = draftFinished ? "終" : ""; const move: Move = { piece: piece.type, @@ -291,7 +296,7 @@ function createMove( type, san: `${arata}${piece.type}${from}${capture}(${to})${betray || tsuke}${draftDone}`, before: fen, - after: '', + after: "", draftFinished, captured, }; @@ -305,7 +310,7 @@ function createMove( function makeMove(move: Move, fen: string) { let { board, hand, mode, turn, drafting, moveNumber } = parseFEN(fen); - const [rank, file, tier] = move.to.split('-'); + const [rank, file, tier] = move.to.split("-"); const to = { type: move.piece, color: move.color, @@ -313,44 +318,44 @@ function makeMove(move: Move, fen: string) { tier: +tier, }; - if (move.type === 'route' || move.type === 'tsuke') { + if (move.type === "route" || move.type === "tsuke") { removeTop(move.from!, board); - } else if (move.type === 'capture') { + } else if (move.type === "capture") { removeTop(move.from!, board); remove(`${rank}-${file}`, move.captured!, board); - } else if (move.type === 'betray') { + } else if (move.type === "betray") { removeTop(move.from!, board); convert(`${rank}-${file}`, move.captured!, board); updateHand(move.captured!, hand, true); - } else if (move.type === 'arata') { + } else if (move.type === "arata") { updateHand([to], hand); if (move.draftFinished) { drafting[move.color] = false; - if (move.color === 'b') drafting.w = false; + if (move.color === "b") drafting.w = false; } } put(to, board); - if (turn === 'b' && (drafting.w || !drafting.b)) moveNumber++; - if (turn === 'w' && !drafting.b && !drafting.w && move.draftFinished) + if (turn === "b" && (drafting.w || !drafting.b)) moveNumber++; + if (turn === "w" && !drafting.b && !drafting.w && move.draftFinished) moveNumber++; if (drafting.w === drafting.b) { - if (!move.draftFinished || turn !== 'w') { - turn = turn === 'b' ? 'w' : 'b'; + if (!move.draftFinished || turn !== "w") { + turn = turn === "b" ? "w" : "b"; } } else { - if (!drafting.b && turn === 'b') { - turn = 'w'; - } else if (!drafting.w && turn === 'w') { - turn = 'b'; + if (!drafting.b && turn === "b") { + turn = "w"; + } else if (!drafting.w && turn === "w") { + turn = "b"; } } - let gameOverSAN = ''; + let gameOverSAN = ""; if (move.captured?.some((p) => p.type === pieceType.marshal)) - gameOverSAN = '#'; + gameOverSAN = "#"; return { after: encodeFEN({ board, hand, mode, turn, drafting, moveNumber }), @@ -382,16 +387,16 @@ function getAttackedSquares(square: string, board: Board): string[] { const piece = getTop(square, board); if (!piece) return []; - const [py, px] = square.split('-').map(Number); + const [py, px] = square.split("-").map(Number); const probes = pieceProbes[piece.type]; return probes.flatMap((probe, i) => { - const pval = typeof probe === 'number' ? probe : probe[0]; - const pcarry = typeof probe === 'number' ? 1 : probe[1]; + const pval = typeof probe === "number" ? probe : probe[0]; + const pcarry = typeof probe === "number" ? 1 : probe[1]; if (pval < 1) return []; let [dy, dx] = dirs[i]; - if (piece.color === 'b') { + if (piece.color === "b") { dy *= -1; dx *= -1; } @@ -407,9 +412,9 @@ function getAttackedSquares(square: string, board: Board): string[] { export function isSquareAttacked( square: string, byColor: Color, - board: Board + board: Board, ): boolean { - const [targetRank, targetFile] = square.split('-').map(Number); + const [targetRank, targetFile] = square.split("-").map(Number); const targetSquare = `${targetRank}-${targetFile}`; for (const sq of SQUARES) { @@ -427,7 +432,7 @@ export function isSquareAttacked( export function inCheck(color: Color, fen: string): boolean { const { board } = parseFEN(fen); - const oppositeColor = color === 'w' ? 'b' : 'w'; + const oppositeColor = color === "w" ? "b" : "w"; // Find marshal position for (const sq of SQUARES) { diff --git a/test/rules-update.test.ts b/test/rules-update.test.ts index 52f5ef7..52f8856 100644 --- a/test/rules-update.test.ts +++ b/test/rules-update.test.ts @@ -1,21 +1,21 @@ -import { describe, expect, it } from 'vitest'; -import type { Board, Color, Move, PieceType } from '../src/gungi/utils'; -import { encodeFEN } from '../src/gungi/fen'; -import { Gungi } from '../src/gungi/gungi'; -import { piece } from '../src/gungi/utils'; +import { describe, expect, it } from "vitest"; +import { encodeFEN } from "../src/gungi/fen"; +import { Gungi } from "../src/gungi/gungi"; +import type { Board, Color, Move, PieceType } from "../src/gungi/utils"; +import { piece } from "../src/gungi/utils"; function createEmptyBoard(): Board { return Array.from({ length: 9 }, () => - Array.from({ length: 9 }, () => [null]) + Array.from({ length: 9 }, () => [null]), ); } function placeTower( board: Board, square: string, - pieces: { type: PieceType; color: Color }[] + pieces: { type: PieceType; color: Color }[], ) { - const [rank, file] = square.split('-').map(Number); + const [rank, file] = square.split("-").map(Number); board[rank - 1][9 - file] = pieces.map((p, i) => ({ square, tier: i + 1, @@ -26,98 +26,183 @@ function placeTower( function getTargets(gungi: Gungi, square: string) { const moves = gungi.moves({ square, verbose: true }); - const verboseMoves = moves.filter((m): m is Move => typeof m !== 'string'); + const verboseMoves = moves.filter((m): m is Move => typeof m !== "string"); return verboseMoves.map((m) => m.to.slice(0, -2)); } function getSanMoves(gungi: Gungi) { - return gungi.moves().filter((m): m is string => typeof m === 'string'); + return gungi.moves().filter((m): m is string => typeof m === "string"); } -describe('Rules Updates', () => { - describe('Draft completion priority', () => { - it('ends draft immediately when black chooses 終', () => { +describe("Rules Updates", () => { + describe("Draft completion priority", () => { + it("ends draft immediately when black chooses 終", () => { const gungi = new Gungi( - '9/9/9/9/9/9/9/9/4M4 G1I1J2W2N3R2S2F2D4C1A2K1T1/m1g1i1j2w2n3r2s2f2d4c1a2k1t1 b 3 wb 1' + "9/9/9/9/9/9/9/9/4M4 G1I1J2W2N3R2S2F2D4C1A2K1T1/m1g1i1j2w2n3r2s2f2d4c1a2k1t1 b 3 wb 1", ); const blackDoneMove = getSanMoves(gungi).find( - (m) => m.startsWith('新帥') && m.endsWith('終') + (m) => m.startsWith("新帥") && m.endsWith("終"), ); expect(blackDoneMove).toBeDefined(); - if (!blackDoneMove) throw new Error('Expected black 終 move'); + if (!blackDoneMove) throw new Error("Expected black 終 move"); gungi.move(blackDoneMove); expect(gungi.inDraft()).toBe(false); expect(gungi.getDraftingRights()).toStrictEqual({ b: false, w: false }); - expect(gungi.turn()).toBe('w'); - expect(gungi.moves({ square: '9-5' }).length).toBeGreaterThan(0); + expect(gungi.turn()).toBe("w"); + expect(gungi.moves({ square: "9-5" }).length).toBeGreaterThan(0); }); - it('keeps draft active when white chooses 終 and allows black to keep placing', () => { - const gungi = new Gungi('4m4/9/9/9/9/9/9/9/4M4 D1/d2 w 3 wb 1'); + it("keeps draft active when white chooses 終 and allows black to keep placing", () => { + const gungi = new Gungi("4m4/9/9/9/9/9/9/9/4M4 D1/d2 w 3 wb 1"); - const whiteDoneMove = getSanMoves(gungi).find((m) => m.endsWith('終')); + const whiteDoneMove = getSanMoves(gungi).find((m) => m.endsWith("終")); expect(whiteDoneMove).toBeDefined(); - if (!whiteDoneMove) throw new Error('Expected white 終 move'); + if (!whiteDoneMove) throw new Error("Expected white 終 move"); gungi.move(whiteDoneMove); expect(gungi.inDraft()).toBe(true); expect(gungi.getDraftingRights()).toStrictEqual({ b: true, w: false }); - expect(gungi.turn()).toBe('b'); + expect(gungi.turn()).toBe("b"); const blackMoves = getSanMoves(gungi); - expect(blackMoves.some((m) => !m.endsWith('終'))).toBe(true); + expect(blackMoves.some((m) => !m.endsWith("終"))).toBe(true); }); }); - describe('Archer wing blocking', () => { - it('blocks a diagonal when its side wing has a higher tower', () => { + describe("Archer wing blocking", () => { + it("blocks a diagonal when its side wing has a higher tower", () => { const board = createEmptyBoard(); - placeTower(board, '5-5', [{ type: piece.archer, color: 'w' }]); - placeTower(board, '4-6', [ - { type: piece.soldier, color: 'b' }, - { type: piece.warrior, color: 'b' }, + placeTower(board, "5-5", [{ type: piece.archer, color: "w" }]); + placeTower(board, "4-6", [ + { type: piece.soldier, color: "b" }, + { type: piece.warrior, color: "b" }, ]); - placeTower(board, '1-1', [{ type: piece.marshal, color: 'b' }]); - placeTower(board, '9-9', [{ type: piece.marshal, color: 'w' }]); + placeTower(board, "1-1", [{ type: piece.marshal, color: "b" }]); + placeTower(board, "9-9", [{ type: piece.marshal, color: "w" }]); const fen = encodeFEN({ board, hand: [], - turn: 'w', - mode: 'advanced', + turn: "w", + mode: "advanced", drafting: { b: false, w: false }, moveNumber: 1, }); const gungi = new Gungi(fen); - const targets = getTargets(gungi, '5-5'); - expect(targets).toContain('3-4'); - expect(targets).not.toContain('3-6'); + const targets = getTargets(gungi, "5-5"); + expect(targets).toContain("3-4"); + expect(targets).not.toContain("3-6"); }); - it('keeps diagonal legal when wing tower is not higher', () => { + it("keeps diagonal legal when wing tower is not higher", () => { const board = createEmptyBoard(); - placeTower(board, '5-5', [{ type: piece.archer, color: 'w' }]); - placeTower(board, '4-6', [{ type: piece.soldier, color: 'b' }]); - placeTower(board, '1-1', [{ type: piece.marshal, color: 'b' }]); - placeTower(board, '9-9', [{ type: piece.marshal, color: 'w' }]); + placeTower(board, "5-5", [{ type: piece.archer, color: "w" }]); + placeTower(board, "4-6", [{ type: piece.soldier, color: "b" }]); + placeTower(board, "1-1", [{ type: piece.marshal, color: "b" }]); + placeTower(board, "9-9", [{ type: piece.marshal, color: "w" }]); const fen = encodeFEN({ board, hand: [], - turn: 'w', - mode: 'advanced', + turn: "w", + mode: "advanced", drafting: { b: false, w: false }, moveNumber: 1, }); const gungi = new Gungi(fen); - const targets = getTargets(gungi, '5-5'); - expect(targets).toContain('3-6'); + const targets = getTargets(gungi, "5-5"); + expect(targets).toContain("3-6"); + }); + }); + + describe("Forward-only leap for jumping pieces", () => { + it("allows cannon to leap forward but not sideways/backward", () => { + const board = createEmptyBoard(); + placeTower(board, "5-5", [ + { type: piece.soldier, color: "w" }, + { type: piece.warrior, color: "w" }, + { type: piece.cannon, color: "w" }, + ]); + placeTower(board, "2-5", [{ type: piece.soldier, color: "b" }]); + placeTower(board, "5-6", [{ type: piece.soldier, color: "b" }]); + placeTower(board, "6-5", [{ type: piece.soldier, color: "b" }]); + placeTower(board, "1-1", [{ type: piece.marshal, color: "b" }]); + placeTower(board, "9-9", [{ type: piece.marshal, color: "w" }]); + + const fen = encodeFEN({ + board, + hand: [], + turn: "w", + mode: "advanced", + drafting: { b: false, w: false }, + moveNumber: 1, + }); + const gungi = new Gungi(fen); + + const targets = getTargets(gungi, "5-5"); + expect(targets).toContain("1-5"); + expect(targets).not.toContain("5-7"); + expect(targets).not.toContain("7-5"); + }); + + it("allows archer to leap forward but not backward", () => { + const board = createEmptyBoard(); + placeTower(board, "5-5", [ + { type: piece.soldier, color: "w" }, + { type: piece.warrior, color: "w" }, + { type: piece.archer, color: "w" }, + ]); + placeTower(board, "3-5", [{ type: piece.soldier, color: "b" }]); + placeTower(board, "6-5", [{ type: piece.soldier, color: "b" }]); + placeTower(board, "1-1", [{ type: piece.marshal, color: "b" }]); + placeTower(board, "9-9", [{ type: piece.marshal, color: "w" }]); + + const fen = encodeFEN({ + board, + hand: [], + turn: "w", + mode: "advanced", + drafting: { b: false, w: false }, + moveNumber: 1, + }); + const gungi = new Gungi(fen); + + const targets = getTargets(gungi, "5-5"); + expect(targets).toContain("1-5"); + expect(targets).not.toContain("7-5"); + }); + + it("allows musketeer to leap forward but not backward diagonals", () => { + const board = createEmptyBoard(); + placeTower(board, "5-5", [ + { type: piece.soldier, color: "w" }, + { type: piece.warrior, color: "w" }, + { type: piece.musketeer, color: "w" }, + ]); + placeTower(board, "3-5", [{ type: piece.soldier, color: "b" }]); + placeTower(board, "6-6", [{ type: piece.soldier, color: "b" }]); + placeTower(board, "1-1", [{ type: piece.marshal, color: "b" }]); + placeTower(board, "9-9", [{ type: piece.marshal, color: "w" }]); + + const fen = encodeFEN({ + board, + hand: [], + turn: "w", + mode: "advanced", + drafting: { b: false, w: false }, + moveNumber: 1, + }); + const gungi = new Gungi(fen); + + const targets = getTargets(gungi, "5-5"); + expect(targets).toContain("1-5"); + expect(targets).not.toContain("7-7"); }); }); });