Merge pull request #365 from johndoknjas/remove-premove-trimming

Remove premove trimming
This commit is contained in:
Thibault Duplessis
2025-11-17 12:03:17 +01:00
committed by GitHub
5 changed files with 66 additions and 291 deletions
-1
View File
@@ -47,7 +47,6 @@ export interface Config {
castle?: boolean; // whether to allow king castle premoves
dests?: cg.Key[]; // premove destinations for the current selection
customDests?: cg.Dests; // use custom valid premoves. {"a2" ["a3" "a4"] "b1" ["a3" "c3"]}
unrestrictedPremoves?: boolean; // if falsy, the positions of friendly pieces will be used to trim premove options
additionalPremoveRequirements?: cg.Mobility;
events?: {
set?: (orig: cg.Key, dest: cg.Key, metadata?: cg.SetPremoveMetadata) => void; // called after the premove has been set
+13 -181
View File
@@ -3,204 +3,36 @@ import * as cg from './types.js';
import { HeadlessState } from './state.js';
import { Mobility, MobilityContext } from './types.js';
const isDestOccupiedByFriendly = (ctx: MobilityContext): boolean => ctx.friendlies.has(ctx.dest.key);
const pawn: Mobility = (ctx: MobilityContext) =>
util.diff(ctx.orig.pos[0], ctx.dest.pos[0]) <= 1 &&
(util.diff(ctx.orig.pos[0], ctx.dest.pos[0]) === 1
? ctx.dest.pos[1] === ctx.orig.pos[1] + (ctx.color === 'white' ? 1 : -1)
: util.pawnDirAdvance(...ctx.orig.pos, ...ctx.dest.pos, ctx.color === 'white'));
const isDestOccupiedByEnemy = (ctx: MobilityContext): boolean => ctx.enemies.has(ctx.dest.key);
const knight: Mobility = (ctx: MobilityContext) => util.knightDir(...ctx.orig.pos, ...ctx.dest.pos);
const anyPieceBetween = (orig: cg.Pos, dest: cg.Pos, pieces: cg.Pieces): boolean =>
util.squaresBetween(...orig, ...dest).some(s => pieces.has(s));
const bishop: Mobility = (ctx: MobilityContext) => util.bishopDir(...ctx.orig.pos, ...ctx.dest.pos);
const canEnemyPawnAdvanceToSquare = (pawnStart: cg.Key, dest: cg.Key, ctx: MobilityContext): boolean => {
const piece = ctx.enemies.get(pawnStart);
if (piece?.role !== 'pawn') return false;
const step = piece.color === 'white' ? 1 : -1;
const startPos = util.key2pos(pawnStart);
const destPos = util.key2pos(dest);
return (
util.pawnDirAdvance(...startPos, ...destPos, piece.color === 'white') &&
!anyPieceBetween(startPos, [destPos[0], destPos[1] + step], ctx.allPieces)
);
};
const canEnemyPawnCaptureOnSquare = (pawnStart: cg.Key, dest: cg.Key, ctx: MobilityContext): boolean => {
const enemyPawn = ctx.enemies.get(pawnStart);
return (
enemyPawn?.role === 'pawn' &&
util.pawnDirCapture(...util.key2pos(pawnStart), ...util.key2pos(dest), enemyPawn.color === 'white') &&
(ctx.friendlies.has(dest) ||
canBeCapturedBySomeEnemyEnPassant(
util.squareShiftedVertically(dest, enemyPawn.color === 'white' ? -1 : 1),
ctx.friendlies,
ctx.enemies,
ctx.lastMove,
))
);
};
const canSomeEnemyPawnAdvanceToDest = (ctx: MobilityContext): boolean =>
[...ctx.enemies.keys()].some(key => canEnemyPawnAdvanceToSquare(key, ctx.dest.key, ctx));
const isDestControlledByEnemy = (ctx: MobilityContext, pieceRolesExclude?: cg.Role[]): boolean => {
const square: cg.Pos = ctx.dest.pos;
return [...ctx.enemies].some(([key, piece]) => {
const piecePos = util.key2pos(key);
return (
!pieceRolesExclude?.includes(piece.role) &&
((piece.role === 'pawn' && util.pawnDirCapture(...piecePos, ...square, piece.color === 'white')) ||
(piece.role === 'knight' && util.knightDir(...piecePos, ...square)) ||
(piece.role === 'bishop' && util.bishopDir(...piecePos, ...square)) ||
(piece.role === 'rook' && util.rookDir(...piecePos, ...square)) ||
(piece.role === 'queen' && util.queenDir(...piecePos, ...square)) ||
(piece.role === 'king' && util.kingDirNonCastling(...piecePos, ...square))) &&
(!['bishop', 'rook', 'queen'].includes(piece.role) || !anyPieceBetween(piecePos, square, ctx.allPieces))
);
});
};
const isFriendlyOnDestAndAttacked = (ctx: MobilityContext): boolean =>
isDestOccupiedByFriendly(ctx) &&
(canBeCapturedBySomeEnemyEnPassant(ctx.dest.key, ctx.friendlies, ctx.enemies, ctx.lastMove) ||
isDestControlledByEnemy(ctx));
const canBeCapturedBySomeEnemyEnPassant = (
potentialSquareOfFriendlyPawn: cg.Key | undefined,
friendlies: cg.Pieces,
enemies: cg.Pieces,
lastMove?: cg.Key[],
): boolean => {
if (!potentialSquareOfFriendlyPawn || (lastMove && potentialSquareOfFriendlyPawn !== lastMove[1]))
return false;
const pos = util.key2pos(potentialSquareOfFriendlyPawn);
const friendly = friendlies.get(potentialSquareOfFriendlyPawn);
return (
friendly?.role === 'pawn' &&
pos[1] === (friendly.color === 'white' ? 3 : 4) &&
(!lastMove || util.diff(util.key2pos(lastMove[0])[1], pos[1]) === 2) &&
[1, -1].some(delta => {
const k = util.pos2key([pos[0] + delta, pos[1]]);
return !!k && enemies.get(k)?.role === 'pawn';
})
);
};
const isPathClearEnoughOfFriendliesForPremove = (ctx: MobilityContext, isPawnAdvance: boolean): boolean => {
if (ctx.unrestrictedPremoves) return true;
const squaresBetween = util.squaresBetween(...ctx.orig.pos, ...ctx.dest.pos);
if (isPawnAdvance) squaresBetween.push(ctx.dest.key);
const squaresOfFriendliesBetween = squaresBetween.filter(s => ctx.friendlies.has(s));
if (!squaresOfFriendliesBetween.length) return true;
const firstSquareOfFriendliesBetween = squaresOfFriendliesBetween[0];
const nextSquare = util.squareShiftedVertically(
firstSquareOfFriendliesBetween,
ctx.color === 'white' ? -1 : 1,
);
return (
squaresOfFriendliesBetween.length === 1 &&
canBeCapturedBySomeEnemyEnPassant(
firstSquareOfFriendliesBetween,
ctx.friendlies,
ctx.enemies,
ctx.lastMove,
) &&
!!nextSquare &&
!squaresBetween.includes(nextSquare)
);
};
const isPathClearEnoughOfEnemiesForPremove = (ctx: MobilityContext, isPawnAdvance: boolean): boolean => {
if (ctx.unrestrictedPremoves) return true;
const squaresBetween = util.squaresBetween(...ctx.orig.pos, ...ctx.dest.pos);
if (isPawnAdvance) squaresBetween.push(ctx.dest.key);
const squaresOfEnemiesBetween = squaresBetween.filter(s => ctx.enemies.has(s));
if (squaresOfEnemiesBetween.length > 1) return false;
if (!squaresOfEnemiesBetween.length) return true;
const enemySquare = squaresOfEnemiesBetween[0];
const enemy = ctx.enemies.get(enemySquare);
if (!enemy || enemy.role !== 'pawn') return true;
const enemyStep = enemy.color === 'white' ? 1 : -1;
const squareAbove = util.squareShiftedVertically(enemySquare, enemyStep);
const enemyPawnDests: cg.Key[] = squareAbove
? [
...util.adjacentSquares(squareAbove).filter(s => canEnemyPawnCaptureOnSquare(enemySquare, s, ctx)),
...[squareAbove, util.squareShiftedVertically(squareAbove, enemyStep)]
.filter(s => !!s)
.filter(s => canEnemyPawnAdvanceToSquare(enemySquare, s, ctx)),
]
: [];
const badSquares = [...squaresBetween, ctx.orig.key];
return enemyPawnDests.some(square => !badSquares.includes(square));
};
const isPathClearEnoughForPremove = (ctx: MobilityContext, isPawnAdvance: boolean): boolean =>
isPathClearEnoughOfFriendliesForPremove(ctx, isPawnAdvance) &&
isPathClearEnoughOfEnemiesForPremove(ctx, isPawnAdvance);
const pawn: Mobility = (ctx: MobilityContext) => {
const step = ctx.color === 'white' ? 1 : -1;
if (util.diff(ctx.orig.pos[0], ctx.dest.pos[0]) > 1) return false;
if (!util.diff(ctx.orig.pos[0], ctx.dest.pos[0]))
return (
util.pawnDirAdvance(...ctx.orig.pos, ...ctx.dest.pos, ctx.color === 'white') &&
isPathClearEnoughForPremove(ctx, true)
);
if (ctx.dest.pos[1] !== ctx.orig.pos[1] + step) return false;
if (ctx.unrestrictedPremoves || isDestOccupiedByEnemy(ctx)) return true;
if (isDestOccupiedByFriendly(ctx)) return isDestControlledByEnemy(ctx);
else
return (
canSomeEnemyPawnAdvanceToDest(ctx) ||
canBeCapturedBySomeEnemyEnPassant(
util.pos2key([ctx.dest.pos[0], ctx.dest.pos[1] + step]),
ctx.friendlies,
ctx.enemies,
ctx.lastMove,
) ||
isDestControlledByEnemy(ctx, ['pawn'])
);
};
const knight: Mobility = (ctx: MobilityContext) =>
util.knightDir(...ctx.orig.pos, ...ctx.dest.pos) &&
(ctx.unrestrictedPremoves || !isDestOccupiedByFriendly(ctx) || isFriendlyOnDestAndAttacked(ctx));
const bishop: Mobility = (ctx: MobilityContext) =>
util.bishopDir(...ctx.orig.pos, ...ctx.dest.pos) &&
isPathClearEnoughForPremove(ctx, false) &&
(ctx.unrestrictedPremoves || !isDestOccupiedByFriendly(ctx) || isFriendlyOnDestAndAttacked(ctx));
const rook: Mobility = (ctx: MobilityContext) =>
util.rookDir(...ctx.orig.pos, ...ctx.dest.pos) &&
isPathClearEnoughForPremove(ctx, false) &&
(ctx.unrestrictedPremoves || !isDestOccupiedByFriendly(ctx) || isFriendlyOnDestAndAttacked(ctx));
const rook: Mobility = (ctx: MobilityContext) => util.rookDir(...ctx.orig.pos, ...ctx.dest.pos);
const queen: Mobility = (ctx: MobilityContext) => bishop(ctx) || rook(ctx);
const king: Mobility = (ctx: MobilityContext) =>
(util.kingDirNonCastling(...ctx.orig.pos, ...ctx.dest.pos) &&
(ctx.unrestrictedPremoves || !isDestOccupiedByFriendly(ctx) || isFriendlyOnDestAndAttacked(ctx))) ||
util.kingDirNonCastling(...ctx.orig.pos, ...ctx.dest.pos) ||
(ctx.canCastle &&
ctx.orig.pos[1] === ctx.dest.pos[1] &&
ctx.orig.pos[1] === (ctx.color === 'white' ? 0 : 7) &&
((ctx.orig.pos[0] === 4 &&
((ctx.dest.pos[0] === 2 && ctx.rookFilesFriendlies.includes(0)) ||
(ctx.dest.pos[0] === 6 && ctx.rookFilesFriendlies.includes(7)))) ||
ctx.rookFilesFriendlies.includes(ctx.dest.pos[0])) &&
(ctx.unrestrictedPremoves ||
/* The following checks if no non-rook friendly piece is in the way between the king and its castling destination.
Note that for the Chess960 edge case of Kb1 "long castling", the check passes even if there is a piece in the way
on c1. But this is fine, since premoving from b1 to a1 as a normal move would have already returned true. */
util
.squaresBetween(...ctx.orig.pos, ctx.dest.pos[0] > ctx.orig.pos[0] ? 7 : 1, ctx.dest.pos[1])
.map(s => ctx.allPieces.get(s))
.every(p => !p || util.samePiece(p, { role: 'rook', color: ctx.color }))));
ctx.rookFilesFriendlies.includes(ctx.dest.pos[0])));
const mobilityByRole = { pawn, knight, bishop, rook, queen, king };
export function premove(state: HeadlessState, key: cg.Key): cg.Key[] {
// TODO - remove `castle` once https://github.com/lichess-org/lila/pull/18630 is merged.
const pieces = state.pieces,
canCastle = state.premovable.castle,
unrestrictedPremoves = !!state.premovable.unrestrictedPremoves;
canCastle = state.premovable.castle;
const piece = pieces.get(key);
if (!piece || piece.color === state.turnColor) return [];
const color = piece.color,
@@ -215,7 +47,6 @@ export function premove(state: HeadlessState, key: cg.Key): cg.Key[] {
allPieces: pieces,
friendlies: friendlies,
enemies: enemies,
unrestrictedPremoves: unrestrictedPremoves,
color: color,
canCastle: canCastle,
rookFilesFriendlies: Array.from(pieces)
@@ -225,5 +56,6 @@ export function premove(state: HeadlessState, key: cg.Key): cg.Key[] {
.map(([k]) => util.key2pos(k)[0]),
lastMove: state.lastMove,
};
// todo - remove more properties from MobilityContext that aren't used in this file, and adjust as needed in lila.
return util.allPosAndKey.filter(dest => mobility({ ...partialCtx, dest })).map(pk => pk.key);
}
-1
View File
@@ -52,7 +52,6 @@ export interface HeadlessState {
dests?: cg.Key[]; // premove destinations for the current selection
customDests?: cg.Dests; // use custom valid premoves. {"a2" ["a3" "a4"] "b1" ["a3" "c3"]}
current?: cg.KeyPair; // keys of the current saved premove ["e2" "e4"]
unrestrictedPremoves?: boolean; // if falsy, the positions of friendly pieces will be used to trim premove options
additionalPremoveRequirements: cg.Mobility;
events: {
set?: (orig: cg.Key, dest: cg.Key, metadata?: cg.SetPremoveMetadata) => void; // called after the premove has been set
-1
View File
@@ -124,7 +124,6 @@ export type MobilityContext = {
allPieces: Pieces;
friendlies: Pieces;
enemies: Pieces;
unrestrictedPremoves: boolean;
color: Color;
canCastle: boolean;
rookFilesFriendlies: number[];
+53 -107
View File
@@ -4,27 +4,22 @@ import { defaults, HeadlessState } from '../src/state';
import * as fen from '../src/fen';
import * as util from '../src/util';
const diagonallyOpposite = (square: cg.Key): cg.Key =>
util.pos2keyUnsafe(util.key2pos(square).map(n => 7 - n) as cg.Pos);
const invertSquare = (square: cg.Key): cg.Key => {
const asPos = util.key2pos(square);
return util.pos2keyUnsafe([asPos[0], 7 - asPos[1]] as cg.Pos);
};
const invertPieces = (pieces: cg.Pieces): cg.Pieces =>
new Map(
[...pieces].map(([key, piece]) => [
diagonallyOpposite(key),
invertSquare(key),
{ role: piece.role, color: util.opposite(piece.color) },
]),
);
const makeState = (
pieces: cg.Pieces,
trimPremoves: boolean,
lastMove: cg.Key[] | undefined,
turnColor: cg.Color,
): HeadlessState => {
const makeState = (pieces: cg.Pieces, turnColor: cg.Color): HeadlessState => {
const state = defaults();
state.pieces = pieces;
if (!trimPremoves) state.premovable.unrestrictedPremoves = true;
state.lastMove = lastMove;
state.turnColor = turnColor;
return state;
};
@@ -32,11 +27,10 @@ const makeState = (
const testPosition = (
pieces: cg.Pieces,
turnColor: cg.Color,
lastMove: cg.Key[] | undefined,
expectedPremoves: Map<cg.Key, Set<cg.Key>>,
checkInverseToo: boolean,
): void => {
const state = makeState(pieces, true, lastMove, turnColor);
const state = makeState(pieces, turnColor);
for (const [from, expectedDests] of expectedPremoves) {
expect(new Set(premove(state, from))).toEqual(expectedDests);
}
@@ -47,112 +41,64 @@ const testPosition = (
testPosition(
invertPieces(pieces),
util.opposite(turnColor),
lastMove?.map(sq => diagonallyOpposite(sq)),
new Map(
[...expectedPremoves].map(([start, dests]) => [
diagonallyOpposite(start),
new Set(Array.from(dests, diagonallyOpposite)),
invertSquare(start),
new Set(Array.from(dests, invertSquare)),
]),
),
false,
);
};
test('premoves are trimmed appropriately', () => {
test('premoves are found', () => {
const expectedPremoves = new Map<cg.Key, Set<cg.Key>>([
['f8', new Set(['g8', 'e8', 'd8', 'c8', 'f7', 'f6', 'f5', 'f4', 'f3', 'f2', 'f1'])],
['f1', new Set(['h1', 'g1', 'e1', 'd1', 'c1', 'b1', 'a1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7'])],
['g5', new Set(['h5', 'f5', 'e5', 'd5', 'c5', 'g6'])],
['h8', new Set(['h7', 'g8'])],
['e3', new Set(['g3', 'f3', 'd3', 'c3', 'b3', 'e2', 'e1', 'e4', 'e5', 'e6'])],
['d6', new Set(['e6', 'f6', 'g6', 'c6', 'b6', 'a6', 'd7', 'd8', 'd5', 'd4', 'd3', 'd2', 'd1'])],
['h1', new Set(['h2', 'h3', 'h4', 'g1', 'f1', 'g2', 'f3', 'e4', 'd5', 'c6', 'b7'])],
['a4', new Set(['b3', 'b4', 'c4', 'd4', 'e4', 'f4', 'a5', 'a6'])],
['c5', new Set(['c4', 'c3', 'd4', 'e3', 'd5', 'e5', 'f5', 'c6', 'b6', 'a7', 'b4'])],
['a8', new Set(['b8', 'b7', 'a7'])],
['c8', new Set(['e7', 'b6', 'a7'])],
['g4', new Set(['h2', 'f6', 'e5', 'e3', 'f2'])],
['c2', new Set(['e1', 'e3', 'd4', 'b4', 'a1'])],
['b2', new Set(['c1', 'a1', 'c3', 'd4', 'e5', 'f6'])],
['c7', new Set(['d8', 'b8', 'b6', 'a5'])],
['h6', new Set(['h5'])],
['g7', new Set(['g6', 'f6'])],
['e5', new Set(['e4', 'd4'])],
['b5', new Set(['b4'])],
['a3', new Set([])],
['a7', new Set(['a6', 'a5', 'b6'])],
['b7', new Set(['b6', 'b5', 'a6', 'c6'])],
['c7', new Set(['c6', 'c5', 'b6', 'd6'])],
['d7', new Set(['d6', 'd5', 'c6', 'e6'])],
['e7', new Set(['e6', 'e5', 'd6', 'f6'])],
['f6', new Set(['f5', 'e5', 'g5'])],
['g7', new Set(['g6', 'g5', 'f6', 'h6'])],
['h7', new Set(['h6', 'h5', 'g6'])],
['a8', new Set(['a7', 'a6', 'a5', 'a4', 'a3', 'a2', 'a1', 'b8', 'c8', 'd8', 'e8', 'f8', 'g8', 'h8'])],
['b8', new Set(['a6', 'c6', 'd7'])],
['c8', new Set(['a6', 'b7', 'd7', 'e6', 'f5', 'g4', 'h3'])],
[
'd8',
new Set([
'd7',
'd6',
'd5',
'd4',
'd3',
'd2',
'd1',
'e8',
'f8',
'g8',
'h8',
'c8',
'b8',
'a8',
'e7',
'f6',
'g5',
'h4',
'c7',
'b6',
'a5',
]),
],
['e8', new Set(['d8', 'f8', 'd7', 'e7', 'f7', 'g8', 'h8', 'c8', 'a8'])],
['f8', new Set(['g7', 'h6', 'e7', 'd6', 'c5', 'b4', 'a3'])],
['g8', new Set(['h6', 'f6', 'e7'])],
['h8', new Set(['h7', 'h6', 'h5', 'h4', 'h3', 'h2', 'h1', 'g8', 'f8', 'e8', 'd8', 'c8', 'b8', 'a8'])],
]);
testPosition(
fen.read('k1n2r1r/2bP2p1/3r3p/Ppq1pPr1/qP4n1/p3r1P1/PbnP2KP/R4r1q w - - 0 1'),
fen.read('rnbqkbnr/ppppp1pp/5p2/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'),
'white',
['e7', 'e5'],
expectedPremoves,
true,
);
});
test('anticipate all en passant captures if no last move', () => {
const expectedPremoves = new Map<cg.Key, Set<cg.Key>>([
['a2', new Set(['b1', 'b3', 'c4', 'd5', 'e6', 'f7', 'g8'])],
['h2', new Set(['g1', 'g3', 'f4', 'e5', 'd6', 'c7', 'b8'])],
['h3', new Set(['g3', 'f3', 'e3', 'd3', 'h4', 'h5'])],
['f5', new Set(['e5', 'd5', 'c5', 'b5', 'a5', 'f6', 'f7', 'f8', 'f4', 'f3'])],
['c4', new Set(['c5'])],
['f4', new Set([])],
['g5', new Set(['g6'])],
['d3', new Set(['d4', 'e4'])],
]);
testPosition(
fen.read('8/8/8/5RPp/1pP1pP2/3Pp2R/B6B/8 b - - 0 1'),
'black',
undefined,
expectedPremoves,
true,
);
});
test('horde no en passant for first to third rank', () => {
const expectedPremoves = new Map<cg.Key, Set<cg.Key>>([
['f1', new Set(['f2', 'f3'])],
['g3', new Set(['g4', 'h4'])],
]);
testPosition(
fen.read('rnbqkbnr/ppppppp1/8/8/8/6Pp/8/5P2 w kq - 0 1'),
'black',
['g1', 'g3'],
expectedPremoves,
true,
);
});
test('prod bug report lichess-org/lila#18224', () => {
const expectedPremoves = new Map<cg.Key, Set<cg.Key>>([
['a8', new Set(['a7', 'a6', 'a5', 'a4', 'a3', 'a2', 'b8', 'c8', 'd8', 'e8', 'f8', 'g8', 'h8'])],
['f2', new Set(['f3', 'g3'])],
['g2', new Set(['h1', 'g1', 'f1', 'h2', 'h3', 'g3', 'f3'])],
]);
testPosition(
fen.read('R7/6k1/8/8/5pp1/8/p4PK1/r7 b - - 0 56'),
'black',
['h2', 'g2'],
expectedPremoves,
false,
);
});
test('promotion premove allowed', () => {
const expectedPremoves = new Map<cg.Key, Set<cg.Key>>([
['c7', new Set(['c8', 'b8', 'd8'])],
['e7', new Set(['d8', 'e8', 'f8'])],
['g7', new Set(['f8'])],
['g8', new Set(['f8', 'e8', 'd8', 'c8', 'b8', 'a8'])],
['h7', new Set(['g8'])],
['a7', new Set(['a8', 'b8'])],
['c1', new Set(['b1', 'b2', 'c2', 'd2', 'd1'])],
]);
testPosition(
fen.read('n3r1RB/PkP1P1PP/8/8/8/8/8/2K5 b - - 0 1'),
'black',
undefined,
expectedPremoves,
true,
);