Skip to content

Commit d31e589

Browse files
committed
Improve portal rules and puzzle UX
1 parent aeee894 commit d31e589

5 files changed

Lines changed: 221 additions & 12 deletions

File tree

src/engine/__tests__/portal.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,30 @@ describe("Portal Chess: portal creation", () => {
6464
const ns = makeMove(s, cap[0]);
6565
expect(ns.portals?.b).toEqual([parseSquare("d5")]);
6666
});
67+
68+
it("Pawn on own portal blocks access and moving off does not consume it", () => {
69+
const s = asPortal(parseFEN("4k3/8/8/8/8/8/4P3/4K1N1 w - - 0 1"));
70+
s.portals = { w: [parseSquare("e2")], b: [], max: 1 };
71+
72+
const pawnMoves = legalMovesFrom(s, parseSquare("e2"));
73+
expect(pawnMoves.some((m) => m.isPortalEntry)).toBe(false);
74+
const up = pawnMoves.find((m) => sqEq(m.to, parseSquare("e3")));
75+
expect(up).toBeDefined();
76+
77+
let ns = makeMove(s, up!);
78+
expect(ns.portals?.w).toEqual([parseSquare("e2")]);
79+
80+
// Skip black's turn in this unit test and continue from white's side.
81+
ns.turn = "w";
82+
const ontoPortal = legalMovesFrom(ns, parseSquare("g1"))
83+
.find((m) => sqEq(m.to, parseSquare("e2")));
84+
expect(ontoPortal).toBeDefined();
85+
86+
ns = makeMove(ns, ontoPortal!);
87+
ns.turn = "w";
88+
const tele = legalMovesFrom(ns, parseSquare("e2")).filter((m) => m.isPortalEntry);
89+
expect(tele.length).toBeGreaterThan(0);
90+
});
6791
});
6892

6993
describe("Portal Chess: teleport entry", () => {

src/engine/rules.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -303,13 +303,12 @@ export function makeMove(state: GameState, move: Move): GameState {
303303
// Capture happens at move.to. Clear the destination square (handles capture).
304304
ns.board[move.to.rank][move.to.file] = null;
305305

306-
// Portal Chess (deferred warp): the portal under a non-creator piece is
307-
// consumed when the piece leaves it (whether by normal move or teleport).
308-
// The creator piece doesn't use portals, so its own movement never consumes
309-
// a portal under it (the creator simply leaves it behind).
306+
// Portal Chess (deferred warp): only non-pawn, non-creator pieces consume
307+
// their own portal when leaving it (normal move or teleport). Pawns simply
308+
// stand on portals and block access until they move away.
310309
if (ns.portals && ns.portalCreators) {
311310
const creator = ns.portalCreators[piece.color];
312-
if (piece.type !== creator) {
311+
if (piece.type !== "P" && piece.type !== creator) {
313312
const ownPortalAtFrom = ns.portals[piece.color].some((p) => sqEq(p, move.from));
314313
if (ownPortalAtFrom) {
315314
ns.portals[piece.color] = ns.portals[piece.color].filter((p) => !sqEq(p, move.from));

src/puzzles/portal-puzzle-db.ts

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,86 @@ function isLight(s: string): boolean {
147147
return (sq.file + sq.rank) % 2 === 1;
148148
}
149149

150+
const GENERIC_THEMES = new Set(["portal", "queen", "rook", "bishop", "knight", "mateIn1", "mateIn2"]);
151+
152+
function candidateKey(c: RawCandidate): string {
153+
return `${c.fen}|${c.wPortal ?? "-"}|${c.bPortal ?? "-"}|${c.moves.join(",")}`;
154+
}
155+
156+
function motifOf(c: RawCandidate): string {
157+
return c.themes.find((t) => !GENERIC_THEMES.has(t)) ?? "misc";
158+
}
159+
160+
function spreadPick<T>(items: T[], count: number): T[] {
161+
if (count <= 0 || items.length === 0) return [];
162+
if (items.length <= count) return items.slice();
163+
if (count === 1) return [items[Math.floor(items.length / 2)]];
164+
const out: T[] = [];
165+
for (let i = 0; i < count; i++) {
166+
const idx = Math.round((i * (items.length - 1)) / (count - 1));
167+
out.push(items[idx]);
168+
}
169+
return out;
170+
}
171+
172+
function curateCandidates(valid: RawCandidate[], targetCount = 20): RawCandidate[] {
173+
if (valid.length <= targetCount) return valid.slice();
174+
175+
const quotas: Record<string, number> = {
176+
captureThenWarp: 5,
177+
anastasia: 4,
178+
"smothered-h6": 2,
179+
"smothered-a6": 2,
180+
rookLiftWhite: 4,
181+
rookLiftBlack: 3,
182+
};
183+
184+
const byMotif = new Map<string, RawCandidate[]>();
185+
for (const c of valid) {
186+
const motif = motifOf(c);
187+
const arr = byMotif.get(motif) ?? [];
188+
arr.push(c);
189+
byMotif.set(motif, arr);
190+
}
191+
for (const arr of byMotif.values()) {
192+
arr.sort((a, b) => candidateKey(a).localeCompare(candidateKey(b)));
193+
}
194+
195+
const picked: RawCandidate[] = [];
196+
const used = new Set<string>();
197+
const take = (arr: RawCandidate[], n: number) => {
198+
for (const c of spreadPick(arr, n)) {
199+
const key = candidateKey(c);
200+
if (used.has(key)) continue;
201+
used.add(key);
202+
picked.push(c);
203+
}
204+
};
205+
206+
for (const [motif, quota] of Object.entries(quotas)) {
207+
const arr = byMotif.get(motif);
208+
if (!arr || arr.length === 0 || quota <= 0) continue;
209+
take(arr, Math.min(quota, arr.length));
210+
}
211+
212+
if (picked.length < targetCount) {
213+
const rest = valid.slice().sort((a, b) => {
214+
const motifCmp = motifOf(a).localeCompare(motifOf(b));
215+
if (motifCmp !== 0) return motifCmp;
216+
return candidateKey(a).localeCompare(candidateKey(b));
217+
});
218+
for (const c of rest) {
219+
if (picked.length >= targetCount) break;
220+
const key = candidateKey(c);
221+
if (used.has(key)) continue;
222+
used.add(key);
223+
picked.push(c);
224+
}
225+
}
226+
227+
return picked.slice(0, targetCount);
228+
}
229+
150230
// ── pattern generators ──────────────────────────────────────────────────────
151231

152232
/**
@@ -365,6 +445,72 @@ function genCaptureThenWarp(): RawCandidate[] {
365445
return out;
366446
}
367447

448+
/**
449+
* Pattern E — "Rook lift" (white to move).
450+
*
451+
* Black king is boxed on g8 by pawns f7/g7/h7. White rook stands on its own
452+
* portal and teleports to e8 for immediate back-rank mate.
453+
*/
454+
function genRookLiftWhite(): RawCandidate[] {
455+
const out: RawCandidate[] = [];
456+
const fixed: Record<string, string> = {
457+
"g8": "k",
458+
"f7": "p",
459+
"g7": "p",
460+
"h7": "p",
461+
"a1": "K",
462+
};
463+
const rookPortals = [
464+
"b1", "c1", "d1", "e1", "f1", "h1",
465+
"a2", "b2", "c2", "d2", "e2", "f2", "g2", "h2",
466+
];
467+
for (const portal of rookPortals) {
468+
const pieces = { ...fixed, [portal]: "R" };
469+
const fen = `${fenPlacement(pieces)} w - -`;
470+
out.push({
471+
fen,
472+
wPortal: portal,
473+
bPortal: null,
474+
moves: [`${portal}e8`],
475+
themes: ["portal", "rook", "rookLiftWhite", "mateIn1"],
476+
});
477+
}
478+
return out;
479+
}
480+
481+
/**
482+
* Pattern F — "Rook lift" mirror (black to move).
483+
*
484+
* White king is boxed on g1 by pawns f2/g2/h2. Black rook stands on its own
485+
* portal and teleports to e1 for immediate back-rank mate.
486+
*/
487+
function genRookLiftBlack(): RawCandidate[] {
488+
const out: RawCandidate[] = [];
489+
const fixed: Record<string, string> = {
490+
"a8": "k",
491+
"g1": "K",
492+
"f2": "P",
493+
"g2": "P",
494+
"h2": "P",
495+
};
496+
const rookPortals = [
497+
"b8", "c8", "d8", "e8", "f8", "h8",
498+
"a7", "b7", "c7", "d7", "e7", "f7", "g7", "h7",
499+
];
500+
for (const portal of rookPortals) {
501+
const pieces = { ...fixed, [portal]: "r" };
502+
const fen = `${fenPlacement(pieces)} b - -`;
503+
out.push({
504+
fen,
505+
wPortal: null,
506+
bPortal: portal,
507+
moves: [`${portal}e1`],
508+
themes: ["portal", "rook", "rookLiftBlack", "mateIn1"],
509+
});
510+
}
511+
return out;
512+
}
513+
368514
// ── build ───────────────────────────────────────────────────────────────────
369515

370516
function build(): PortalPuzzleRow[] {
@@ -375,11 +521,22 @@ function build(): PortalPuzzleRow[] {
375521
...genKnightSmotherG(),
376522
...genKnightSmotherB(),
377523
...genCaptureThenWarp(),
524+
...genRookLiftWhite(),
525+
...genRookLiftBlack(),
378526
];
379-
const valid: PortalPuzzleRow[] = [];
380-
let n = 1;
527+
528+
const validCandidates: RawCandidate[] = [];
381529
for (const c of candidates) {
382530
if (!validate(c)) continue;
531+
validCandidates.push(c);
532+
}
533+
534+
// Keep the portal set intentionally compact and varied.
535+
const curated = curateCandidates(validCandidates, 20);
536+
537+
const valid: PortalPuzzleRow[] = [];
538+
let n = 1;
539+
for (const c of curated) {
383540
valid.push({
384541
id: `PP${String(n).padStart(3, "0")}`,
385542
fen: c.fen,

src/screens/NewGameScreen.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ export function NewGameScreen() {
145145
</label>
146146
<p className="hint">
147147
When ticked, teleport targets cannot be adjacent to any other piece.
148-
When unticked (default), you can teleport anywhere empty &mdash; or stay
149-
on the portal square (the portal remains active).
148+
When unticked (default), you can teleport to any empty square
149+
except the portal square itself.
150150
</p>
151151
</section>
152152
</>
@@ -197,6 +197,21 @@ export function NewGameScreen() {
197197
</section>
198198
)}
199199

200+
{showBlackName && (
201+
<section>
202+
<h3>Board orientation (2-player)</h3>
203+
<label>
204+
<input
205+
type="checkbox"
206+
checked={store.settings.autoFlip}
207+
onChange={(e) => updateSetting("autoFlip", e.target.checked)}
208+
/>
209+
{" "}Auto-turn board after each move
210+
</label>
211+
<p className="hint">Turn this off to keep the board fixed from White's side.</p>
212+
</section>
213+
)}
214+
200215
{showLevel && (
201216
<section>
202217
<h3>Bot difficulty</h3>

src/screens/PuzzlesScreen.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type PortalPuzzle
1212
} from "../puzzles/portal-puzzles";
1313
import { parseUci, parseSquare, type Square } from "../engine/board";
14+
import { gameResult } from "../engine/rules";
1415

1516
type Status = "solving" | "wrong" | "solved";
1617
type MateFilter = "all" | 1 | 2 | 3;
@@ -84,6 +85,7 @@ export function PuzzlesScreen() {
8485

8586
const pool: (Puzzle | PortalPuzzle)[] = mode === "portal" ? portalPool : stdPool;
8687
const puzzle = pool[index];
88+
const sideToMate = useMemo(() => (puzzle ? puzzle.setup().turn : "w"), [puzzle?.id]);
8789
const totalSolved = activeProfile?.stats.puzzlesSolved ?? 0;
8890

8991
const diffCounts = useMemo(() => {
@@ -152,7 +154,12 @@ export function PuzzlesScreen() {
152154
ok = false;
153155
}
154156

155-
if (!ok) {
157+
// Accept alternative winning moves: if the user has just checkmated,
158+
// count the puzzle as solved even when it differs from the scripted line.
159+
const current = gameResult(state);
160+
const solvedByMate = current.kind === "checkmate" && current.winner === sideToMate;
161+
162+
if (!ok && !solvedByMate) {
156163
setStatus("wrong");
157164
if (attemptedRef.current !== puzzle.id) {
158165
recordPuzzleAttempt(puzzle.id);
@@ -161,6 +168,13 @@ export function PuzzlesScreen() {
161168
return;
162169
}
163170

171+
if (solvedByMate) {
172+
setStatus("solved");
173+
recordPuzzleSolved(puzzle.id);
174+
setPlayedPlies(moved);
175+
return;
176+
}
177+
164178
const nextIdx = plyIndex + 1;
165179
if (nextIdx >= puzzle.moves.length) {
166180
setStatus("solved");
@@ -196,10 +210,10 @@ export function PuzzlesScreen() {
196210
if (!puzzle) return null;
197211
if (status === "solved") return <div className="puzzle-banner good">✅ Solved! Great job.</div>;
198212
if (status === "wrong") return <div className="puzzle-banner bad">❌ Not quite — tap Retry.</div>;
199-
const side = puzzle.setup().turn === "w" ? "White" : "Black";
213+
const side = sideToMate === "w" ? "White" : "Black";
200214
const done = alreadySolved ? " (already solved ✓)" : "";
201215
return <div className="puzzle-banner">You play {side}. Mate in {puzzle.mateIn()} — find the forced win.{done}</div>;
202-
}, [status, puzzle, alreadySolved]);
216+
}, [status, puzzle, alreadySolved, sideToMate]);
203217

204218
const allSolvedHere = mode === "portal"
205219
? basePortalPool.length > 0 && basePortalPool.every((p) => solvedIds.has(p.id))

0 commit comments

Comments
 (0)