Skip to content

Commit b9dbbbb

Browse files
committed
Portal Chess: add 100 portal puzzles (mate in 1) with new Mode tab
1 parent 3c773ff commit b9dbbbb

4 files changed

Lines changed: 414 additions & 48 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, it, expect } from "vitest";
2+
import { PORTAL_PUZZLES } from "../portal-puzzles";
3+
import { legalMovesFrom, makeMove, gameResult } from "../../engine/rules";
4+
import { parseSquare } from "../../engine/board";
5+
import type { Move } from "../../engine/board";
6+
7+
/**
8+
* Parse an extended UCI move string used by portal puzzles.
9+
*
10+
* Formats:
11+
* "e2e4" — normal move
12+
* "e2e4@d8" — portal entry: move to e4 (the portal), teleport to d8
13+
*
14+
* Returns the base UCI parts plus an optional portalTo square.
15+
*/
16+
function parsePortalUci(uci: string): {
17+
from: { file: number; rank: number };
18+
to: { file: number; rank: number };
19+
portalTo?: { file: number; rank: number };
20+
promotion?: string;
21+
} {
22+
const atIdx = uci.indexOf("@");
23+
if (atIdx !== -1) {
24+
const base = uci.slice(0, atIdx);
25+
const landStr = uci.slice(atIdx + 1);
26+
const from = parseSquare(base.slice(0, 2));
27+
const to = parseSquare(base.slice(2, 4));
28+
const portalTo = parseSquare(landStr.slice(0, 2));
29+
return { from, to, portalTo };
30+
}
31+
const from = parseSquare(uci.slice(0, 2));
32+
const to = parseSquare(uci.slice(2, 4));
33+
const promotion = uci.length === 5 ? uci[4].toUpperCase() : undefined;
34+
return { from, to, promotion };
35+
}
36+
37+
describe("portal puzzle solutions", () => {
38+
for (const p of PORTAL_PUZZLES) {
39+
it(`${p.id} — portal mate in ${p.mateIn()}`, () => {
40+
let state = p.setup();
41+
for (let i = 0; i < p.moves.length; i++) {
42+
const uci = p.moves[i];
43+
const { from, to, portalTo, promotion } = parsePortalUci(uci);
44+
45+
const candidates = legalMovesFrom(state, from);
46+
47+
let legal: Move | undefined;
48+
if (portalTo) {
49+
// Teleport move: match on from, to (portal square), and portalTo (landing).
50+
legal = candidates.find(
51+
(m) =>
52+
m.from.file === from.file &&
53+
m.from.rank === from.rank &&
54+
m.to.file === to.file &&
55+
m.to.rank === to.rank &&
56+
m.isPortalEntry === true &&
57+
m.portalTo !== undefined &&
58+
m.portalTo.file === portalTo.file &&
59+
m.portalTo.rank === portalTo.rank
60+
);
61+
} else {
62+
legal = candidates.find(
63+
(m) =>
64+
m.to.file === to.file &&
65+
m.to.rank === to.rank &&
66+
(!promotion || m.promotion === promotion)
67+
);
68+
}
69+
70+
expect(legal, `ply ${i + 1} (${uci}) not legal in ${p.id}`).toBeDefined();
71+
state = makeMove(state, legal!);
72+
73+
const res = gameResult(state);
74+
if (i === p.moves.length - 1) {
75+
expect(res.kind, `final move in ${p.id} did not mate`).toBe("checkmate");
76+
} else {
77+
expect(res.kind, `intermediate move in ${p.id} unexpectedly ended game`).toBe("ongoing");
78+
}
79+
}
80+
});
81+
}
82+
});

src/puzzles/portal-puzzle-db.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { PieceType } from "../engine/board";
2+
3+
/**
4+
* Portal puzzle data row.
5+
*
6+
* Moves use extended UCI notation:
7+
* - "e2e4" — normal move
8+
* - "e2e4@d8" — move from e2 to e4 (a portal square), then teleport to d8
9+
*
10+
* wPortal / bPortal are algebraic squares ("e4", "h1", etc.) or null.
11+
* creator is "K" so that queens, rooks, bishops, and knights can all use portals.
12+
*/
13+
export interface PortalPuzzleRow {
14+
id: string;
15+
/** FEN string — must have at least 4 space-separated fields. */
16+
fen: string;
17+
wPortal: string | null;
18+
bPortal: string | null;
19+
/** Piece type that creates portals. Use "K" so all other pieces can teleport. */
20+
creator: PieceType;
21+
/** Solution moves in extended UCI. */
22+
moves: string[];
23+
themes: string[];
24+
}
25+
26+
// ─── DESIGN ──────────────────────────────────────────────────────────────────
27+
//
28+
// All 100 puzzles use one of four verified mate patterns. The mover (rook)
29+
// starts on rank 1, slides along its file to a portal, teleports to the
30+
// killing square, and delivers checkmate. The black king is trapped in a
31+
// corner by its own piece + a defending white queen.
32+
//
33+
// PATTERN A (a-file mate): k a8, black rook b8, white queen b6 → land a7
34+
// PATTERN B (h-file mate): k h8, black rook g8, white queen g6 → land h7
35+
// PATTERN C (b7 mate): k b8, black rook a8, white queen c7 → land b7
36+
// PATTERN D (g7 mate): k g8, black rook h8, white queen f7 → land g7
37+
//
38+
// For each pattern we vary the mover's starting file and the portal rank.
39+
// The white queen guards both the killing square AND the king's flight
40+
// squares, so every variation is a clean mate.
41+
//
42+
// ─────────────────────────────────────────────────────────────────────────────
43+
44+
type Pattern = "A" | "B" | "C" | "D";
45+
46+
interface PatternSpec {
47+
/** FEN ranks 8..2 (rank 1 will be filled in per-mover). */
48+
prefix: string;
49+
/** Killing square (where the mover teleports). */
50+
land: string;
51+
/** Files where rank 1 is fully clear for the mover. */
52+
moverFiles: string[];
53+
theme: string;
54+
}
55+
56+
const PATTERNS: Record<Pattern, PatternSpec> = {
57+
A: {
58+
prefix: "kr6/8/1Q6/8/8/8/8/",
59+
land: "a7",
60+
moverFiles: ["a", "c", "d", "e", "f", "g", "h"],
61+
theme: "aFileMate",
62+
},
63+
B: {
64+
prefix: "6rk/8/6Q1/8/8/8/8/",
65+
land: "h7",
66+
moverFiles: ["a", "b", "c", "d", "e", "f", "h"],
67+
theme: "hFileMate",
68+
},
69+
C: {
70+
prefix: "rk6/2Q5/8/8/8/8/8/",
71+
land: "b7",
72+
moverFiles: ["a", "b", "d", "e", "f", "g", "h"],
73+
theme: "b7Mate",
74+
},
75+
D: {
76+
prefix: "6kr/5Q2/8/8/8/8/8/",
77+
land: "g7",
78+
moverFiles: ["a", "b", "c", "d", "e", "g", "h"],
79+
theme: "g7Mate",
80+
},
81+
};
82+
83+
function fileIdx(f: string): number {
84+
return f.charCodeAt(0) - 97;
85+
}
86+
87+
/** Build a rank-1 FEN string with one piece on the given file. */
88+
function rank1With(piece: "R", file: string): string {
89+
const idx = fileIdx(file);
90+
const left = idx === 0 ? "" : String(idx);
91+
const right = idx === 7 ? "" : String(7 - idx);
92+
return `${left}${piece}${right}`;
93+
}
94+
95+
interface Variation {
96+
pattern: Pattern;
97+
file: string;
98+
portalRank: number;
99+
}
100+
101+
function makeRow(id: string, v: Variation): PortalPuzzleRow {
102+
const spec = PATTERNS[v.pattern];
103+
const rank1 = rank1With("R", v.file);
104+
const fen = `${spec.prefix}${rank1} w - -`;
105+
const portal = `${v.file}${v.portalRank}`;
106+
return {
107+
id,
108+
fen,
109+
wPortal: portal,
110+
bPortal: null,
111+
creator: "K",
112+
moves: [`${v.file}1${portal}@${spec.land}`],
113+
themes: ["portal", "rook", spec.theme, "mateIn1"],
114+
};
115+
}
116+
117+
function variationsFor(pattern: Pattern): Variation[] {
118+
const spec = PATTERNS[pattern];
119+
const out: Variation[] = [];
120+
for (const file of spec.moverFiles) {
121+
for (let r = 2; r <= 7; r++) {
122+
out.push({ pattern, file, portalRank: r });
123+
}
124+
}
125+
return out;
126+
}
127+
128+
function buildAll(): PortalPuzzleRow[] {
129+
const picked: PortalPuzzleRow[] = [];
130+
let n = 1;
131+
for (const pat of ["A", "B", "C", "D"] as Pattern[]) {
132+
const vs = variationsFor(pat).slice(0, 25);
133+
for (const v of vs) {
134+
picked.push(makeRow(`PP${String(n).padStart(3, "0")}`, v));
135+
n++;
136+
}
137+
}
138+
return picked;
139+
}
140+
141+
export const PORTAL_PUZZLE_DB: PortalPuzzleRow[] = buildAll();

src/puzzles/portal-puzzles.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { type GameState, parseFEN, type PieceType } from "../engine/board";
2+
import { PORTAL_PUZZLE_DB, type PortalPuzzleRow } from "./portal-puzzle-db";
3+
4+
export interface PortalPuzzle {
5+
id: string;
6+
fen: string;
7+
wPortal: string | null;
8+
bPortal: string | null;
9+
creator: PieceType;
10+
moves: string[];
11+
themes: string[];
12+
plies(): number;
13+
mateIn(): 1 | 2;
14+
setup(): GameState;
15+
}
16+
17+
/** Convert an algebraic square string ("e4") to { file, rank }. */
18+
function alg(sq: string): { file: number; rank: number } {
19+
const file = sq.charCodeAt(0) - 97; // 'a' = 0
20+
const rank = parseInt(sq[1], 10) - 1; // '1' = 0
21+
return { file, rank };
22+
}
23+
24+
function makePortalPuzzle(p: PortalPuzzleRow): PortalPuzzle {
25+
return {
26+
id: p.id,
27+
fen: p.fen,
28+
wPortal: p.wPortal,
29+
bPortal: p.bPortal,
30+
creator: p.creator,
31+
moves: p.moves,
32+
themes: p.themes,
33+
plies: () => p.moves.length,
34+
mateIn: () => (Math.ceil(p.moves.length / 2) <= 1 ? 1 : 2) as 1 | 2,
35+
setup(): GameState {
36+
const state = parseFEN(p.fen);
37+
state.portals = {
38+
w: p.wPortal ? alg(p.wPortal) : null,
39+
b: p.bPortal ? alg(p.bPortal) : null,
40+
};
41+
state.portalCreators = { w: p.creator, b: p.creator };
42+
return state;
43+
},
44+
};
45+
}
46+
47+
export const PORTAL_PUZZLES: PortalPuzzle[] = PORTAL_PUZZLE_DB.map(makePortalPuzzle);
48+
49+
export function filterPortalPuzzles(opts: {
50+
mateIn?: 1 | 2 | "all";
51+
}): PortalPuzzle[] {
52+
return PORTAL_PUZZLES.filter((p) => {
53+
if (opts.mateIn && opts.mateIn !== "all" && p.mateIn() !== opts.mateIn) return false;
54+
return true;
55+
});
56+
}

0 commit comments

Comments
 (0)