Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-arata-tactician-betrayal.md
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 51 additions & 15 deletions src/gungi/move_gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PieceType, number>());

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)
Expand Down Expand Up @@ -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);
Expand All @@ -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)
)
);
}
}

Expand All @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -383,6 +396,29 @@ function generateCombinations<T>(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<PieceType, number>());

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 [];
Expand Down
80 changes: 80 additions & 0 deletions test/rules-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});