diff --git a/.changeset/fix-arata-tactician-betrayal.md b/.changeset/fix-arata-tactician-betrayal.md new file mode 100644 index 0000000..4c2d104 --- /dev/null +++ b/.changeset/fix-arata-tactician-betrayal.md @@ -0,0 +1,9 @@ +--- +'gungi.js': patch +--- + +Fix tactician arata betrayal generation when stacking onto a friendly-topped tower. + +- Allow `新謀(... )返X` move generation for this niche arata case. +- Apply betrayal conversion and hand consumption correctly for arata betrayal moves. +- Add regression coverage for both white and black variants. diff --git a/src/gungi/move_gen.ts b/src/gungi/move_gen.ts index 4beeafb..bb3c5ab 100644 --- a/src/gungi/move_gen.ts +++ b/src/gungi/move_gen.ts @@ -156,20 +156,7 @@ export function generateMovesForSquare(square: string, fen: string) { // betrayal if (piece.type === pieceType.tactician && p.color !== piece.color) { - const enemies = t.filter((p) => p.color !== piece.color); - const playerHand = hand.filter((p) => p.color === piece.color); - const enemyCountMap = enemies.reduce((acc, e) => { - acc.set(e.type, (acc.get(e.type) ?? 0) + 1); - return acc; - }, new Map()); - - const betrayalOptions = Array.from(enemyCountMap.entries()) - .filter(([type, count]) => - playerHand.some((p) => p.type === type && p.count >= count) - ) - .flatMap(([type]) => enemies.filter((e) => e.type === type)); - - const combos = generateCombinations(betrayalOptions); + const combos = getBetrayalCombos(t, hand, piece.color); acc.push( ...combos.map((combo) => createMove(piece, `${s}-${p.tier + 1}`, fen, 'betray', combo) @@ -250,6 +237,11 @@ export function generateArata(piece: HandPiece, fen: string) { p.type !== pieceType.marshal) ) { const t = (p?.tier ?? 0) + 1; + const tower = get(s, board); + const betrayalCombos = + piece.type === pieceType.tactician && tower + ? getBetrayalCombos(tower, hand, piece.color) + : []; const playerHandCount = hand .filter((h) => h.color === piece.color) .reduce((sum, h) => sum + h.count, 0); @@ -259,10 +251,25 @@ export function generateArata(piece: HandPiece, fen: string) { // If only 1 piece left, must end draft - no option to continue if (!isLastPiece) { acc.push(createMove(arata, `${s}-${t}`, fen, 'arata')); + acc.push( + ...betrayalCombos.map((combo) => + createMove(arata, `${s}-${t}`, fen, 'arata', combo) + ) + ); } acc.push(createMove(arata, `${s}-${t}`, fen, 'arata', [], true)); + acc.push( + ...betrayalCombos.map((combo) => + createMove(arata, `${s}-${t}`, fen, 'arata', combo, true) + ) + ); } else { acc.push(createMove(arata, `${s}-${t}`, fen, 'arata')); + acc.push( + ...betrayalCombos.map((combo) => + createMove(arata, `${s}-${t}`, fen, 'arata', combo) + ) + ); } } @@ -285,7 +292,9 @@ function createMove( const tsuke = type === 'tsuke' || (toTier !== 1 && toTier - piece.tier > 0) ? '付' : ''; const betray = - type === 'betray' ? `返${captured?.map((p) => p.type).join('')}` : ''; + type === 'betray' || (type === 'arata' && captured && captured.length > 0) + ? `返${captured?.map((p) => p.type).join('')}` + : ''; const draftDone = draftFinished ? '終' : ''; const move: Move = { @@ -329,6 +338,10 @@ function makeMove(move: Move, fen: string) { updateHand(move.captured!, hand, true); } else if (move.type === 'arata') { updateHand([to], hand); + if (move.captured?.length) { + convert(`${rank}-${file}`, move.captured, board); + updateHand(move.captured, hand, true); + } if (move.draftFinished) { drafting[move.color] = false; if (move.color === 'b') drafting.w = false; @@ -383,6 +396,29 @@ function generateCombinations(items: T[]): T[][] { return result; } +function getBetrayalCombos( + tower: Piece[], + hand: HandPiece[], + color: Color +): Piece[][] { + const enemies = tower.filter((p) => p.color !== color); + if (enemies.length === 0) return []; + + const playerHand = hand.filter((p) => p.color === color); + const enemyCountMap = enemies.reduce((acc, e) => { + acc.set(e.type, (acc.get(e.type) ?? 0) + 1); + return acc; + }, new Map()); + + const betrayalOptions = Array.from(enemyCountMap.entries()) + .filter(([type, count]) => + playerHand.some((p) => p.type === type && p.count >= count) + ) + .flatMap(([type]) => enemies.filter((e) => e.type === type)); + + return generateCombinations(betrayalOptions); +} + function getAttackedSquares(square: string, board: Board): string[] { const piece = getTop(square, board); if (!piece) return []; diff --git a/test/rules-update.test.ts b/test/rules-update.test.ts index 16d42cf..5a6fdf6 100644 --- a/test/rules-update.test.ts +++ b/test/rules-update.test.ts @@ -205,4 +205,84 @@ describe('Rules Updates', () => { expect(targets).not.toContain('7-7'); }); }); + + describe('Tactician arata betrayal', () => { + it('allows 新謀 to betray when arata onto a friendly-topped stack', () => { + const board = createEmptyBoard(); + placeTower(board, '5-5', [ + { type: piece.soldier, color: 'b' }, + { type: piece.warrior, 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: [ + { type: piece.tactician, color: 'w', count: 1 }, + { type: piece.soldier, color: 'w', count: 1 }, + ], + turn: 'w', + mode: 'advanced', + drafting: { b: false, w: false }, + moveNumber: 1, + }); + const gungi = new Gungi(fen); + + const sanMoves = getSanMoves(gungi); + expect(sanMoves).toContain('新謀(5-5-3)返兵'); + + gungi.move('新謀(5-5-3)返兵'); + + const movedTower = gungi.get('5-5'); + expect(movedTower?.map((p) => p.type)).toStrictEqual(['兵', '侍', '謀']); + expect(movedTower?.every((p) => p.color === 'w')).toBe(true); + + expect( + gungi.hand('w').find((h) => h.type === piece.tactician) + ).toBeUndefined(); + expect( + gungi.hand('w').find((h) => h.type === piece.soldier) + ).toBeUndefined(); + }); + + it('allows black 新謀 to betray when arata onto a friendly-topped stack', () => { + const board = createEmptyBoard(); + placeTower(board, '5-5', [ + { type: piece.soldier, color: 'w' }, + { type: piece.warrior, 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: [ + { type: piece.tactician, color: 'b', count: 1 }, + { type: piece.soldier, color: 'b', count: 1 }, + ], + turn: 'b', + mode: 'advanced', + drafting: { b: false, w: false }, + moveNumber: 1, + }); + const gungi = new Gungi(fen); + + const sanMoves = getSanMoves(gungi); + expect(sanMoves).toContain('新謀(5-5-3)返兵'); + + gungi.move('新謀(5-5-3)返兵'); + + const movedTower = gungi.get('5-5'); + expect(movedTower?.map((p) => p.type)).toStrictEqual(['兵', '侍', '謀']); + expect(movedTower?.every((p) => p.color === 'b')).toBe(true); + + expect( + gungi.hand('b').find((h) => h.type === piece.tactician) + ).toBeUndefined(); + expect( + gungi.hand('b').find((h) => h.type === piece.soldier) + ).toBeUndefined(); + }); + }); });