Skip to content

Commit 182c020

Browse files
committed
Portal Chess: deferred-warp rules. Landing on own portal does nothing immediately; on a later turn the piece may teleport from the portal to any empty square. Piece cannot stay on portal; portal is spent when the piece leaves. Creator does not consume own portals. Removed click-time portal-target picker (unique destinations make it unnecessary). Rewrote engine tests; portal puzzles are temporarily empty pending regeneration.
1 parent 26432ed commit 182c020

7 files changed

Lines changed: 139 additions & 208 deletions

File tree

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Project conventions and gotchas for AI agents working in this repository.
77
- React 19 + TypeScript (strict), Vite 8, `vite-plugin-pwa`
88
- Vitest 3.2.4 (1400+ tests)
99
- Deployed to GitHub Pages at `https://swon404.github.io/SpeedChess/` (base `/SpeedChess/`)
10-
- Dev server: `npm run dev` (port 5180)
10+
- Dev server: `npm run dev` (port 6180)
1111
- `npm install` requires `--legacy-peer-deps`
1212

1313
## Repo Layout

src/components/Board.tsx

Lines changed: 7 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CSSProperties, useEffect, useState } from "react";
1+
import { CSSProperties, useState } from "react";
22
import { Move, Piece as PieceT, Square, squareName } from "../engine/board";
33
import { findKing, inCheck } from "../engine/rules";
44
import { useGame } from "../GameContext";
@@ -18,51 +18,22 @@ interface PendingPromo {
1818
color: "w" | "b";
1919
}
2020

21-
interface PendingPortal {
22-
from: Square;
23-
to: Square;
24-
targets: Move[];
25-
}
26-
2721
export function Board({ flipped = false }: Props) {
2822
const { state, selected, legalFromSelected, select, tryMove, result, store, hint } = useGame();
2923
const theme = store.settings.theme;
3024
const pieceSet = store.settings.pieceSet;
3125
const [pending, setPending] = useState<PendingPromo | null>(null);
32-
const [portalChoice, setPortalChoice] = useState<PendingPortal | null>(null);
33-
34-
// Cancel pending portal selection if the position changes (e.g. opponent move).
35-
useEffect(() => {
36-
setPortalChoice(null);
37-
}, [state.history.length]);
3826

3927
const ranks = flipped ? [0, 1, 2, 3, 4, 5, 6, 7] : [7, 6, 5, 4, 3, 2, 1, 0];
4028
const files = flipped ? [7, 6, 5, 4, 3, 2, 1, 0] : [0, 1, 2, 3, 4, 5, 6, 7];
4129

4230
const onSquareClick = (sq: Square) => {
4331
if (result.kind !== "ongoing") return;
44-
// If we're picking a teleport target, route the click there first.
45-
if (portalChoice) {
46-
const t = portalChoice.targets.find(
47-
(m) => m.portalTo && m.portalTo.file === sq.file && m.portalTo.rank === sq.rank
48-
);
49-
if (t && t.portalTo) {
50-
tryMove(portalChoice.from, portalChoice.to, undefined, t.portalTo);
51-
setPortalChoice(null);
52-
return;
53-
}
54-
// Any other click cancels the teleport selection.
55-
setPortalChoice(null);
56-
return;
57-
}
5832
const target = isLegalTarget(legalFromSelected, sq);
5933
if (selected && target) {
60-
// Portal entry: gather all teleport options sharing this destination.
61-
const portalCandidates = legalFromSelected.filter(
62-
(m) => m.isPortalEntry && m.to.file === sq.file && m.to.rank === sq.rank
63-
);
64-
if (portalCandidates.length > 0) {
65-
setPortalChoice({ from: selected, to: sq, targets: portalCandidates });
34+
if (target.isPortalEntry) {
35+
// Deferred-warp teleport: destination is unique, no picker needed.
36+
tryMove(selected, sq, undefined, sq);
6637
return;
6738
}
6839
if (target.promotion) {
@@ -84,10 +55,7 @@ export function Board({ flipped = false }: Props) {
8455
: null;
8556
const wPortals = state.portals?.w ?? [];
8657
const bPortals = state.portals?.b ?? [];
87-
const isTeleportMove =
88-
!!lastMove?.isPortalEntry &&
89-
!!lastMove.portalTo &&
90-
!(lastMove.portalTo.file === lastMove.to.file && lastMove.portalTo.rank === lastMove.to.rank);
58+
const isTeleportMove = !!lastMove?.isPortalEntry;
9159

9260
return (
9361
<>
@@ -110,14 +78,12 @@ export function Board({ flipped = false }: Props) {
11078
const isPortalW = wPortals.some((p) => p.file === f && p.rank === r);
11179
const isPortalB = bPortals.some((p) => p.file === f && p.rank === r);
11280
const isTeleportTarget =
113-
portalChoice && portalChoice.targets.some(
114-
(m) => m.portalTo && m.portalTo.file === f && m.portalTo.rank === r
115-
);
81+
!!legal && !!legal.isPortalEntry;
11682
const classes = [
11783
"square",
11884
isLight ? "light" : "dark",
11985
isSelected ? "selected" : "",
120-
legal && !portalChoice ? (piece ? "legal-capture" : "legal-move") : "",
86+
legal && !isTeleportTarget ? (piece ? "legal-capture" : "legal-move") : "",
12187
isTeleportTarget ? "legal-teleport" : "",
12288
(isLastFrom || isLastTo || isLastTeleport) ? "last-move" : "",
12389
isChecked ? "in-check" : "",

src/engine/__tests__/portal.test.ts

Lines changed: 60 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -67,98 +67,68 @@ describe("Portal Chess: portal creation", () => {
6767
});
6868

6969
describe("Portal Chess: teleport entry", () => {
70-
it("Knight landing on a portal is forced to teleport", () => {
71-
// Place a black portal at f3, white knight on g1, no other obstructions.
72-
const s = asPortal(parseFEN("8/8/8/8/8/8/8/6N1 w - - 0 1"));
73-
s.portals = { w: [], b: [parseSquare("f3")], max: 1 };
74-
const moves = legalMovesFrom(s, parseSquare("g1"));
75-
const fMoves = moves.filter((m) => m.to.file === 5 && m.to.rank === 2);
76-
expect(fMoves.length).toBeGreaterThan(0);
77-
// All these must be portal entries.
78-
expect(fMoves.every((m) => m.isPortalEntry && m.portalTo)).toBe(true);
79-
});
80-
81-
it("Bishop teleport is restricted to same-colour squares as the portal", () => {
82-
// Place a portal at e4 (light square). Bishop must teleport to a light square only.
83-
const s = asPortal(parseFEN("8/8/8/8/8/8/8/4B3 w - - 0 1"));
84-
s.portals = { w: [], b: [parseSquare("e4")], max: 1 };
85-
// First move bishop e1 -> e4? bishops can't move file-only. Use diagonal.
86-
// Place bishop at h1 and portal at a8 (both light)? Let's pick portal at d3 (dark).
87-
s.portals = { w: [], b: [parseSquare("d3")], max: 1 };
88-
s.board[0][4] = null;
89-
s.board[0][5] = { type: "B", color: "w" }; // Bf1
90-
const moves = legalMovesFrom(s, parseSquare("f1")).filter((m) => sqEq(m.to, parseSquare("d3")));
70+
it("Knight standing on its own portal has teleport options on next move", () => {
71+
const s = asPortal(parseFEN("8/8/8/8/8/8/8/8 w - - 0 1"));
72+
s.board[2][5] = { type: "N", color: "w" }; // Nf3
73+
s.portals = { w: [parseSquare("f3")], b: [], max: 1 };
74+
const moves = legalMovesFrom(s, parseSquare("f3"));
75+
const tele = moves.filter((m) => m.isPortalEntry);
76+
expect(tele.length).toBeGreaterThan(0);
77+
expect(tele.every((m) => m.portalTo && sqEq(m.portalTo, m.to))).toBe(true);
78+
});
79+
80+
it("Bishop on its own portal teleports only to same-colour squares", () => {
81+
const s = asPortal(parseFEN("8/8/8/8/8/8/8/8 w - - 0 1"));
82+
s.board[2][3] = { type: "B", color: "w" }; // Bd3
83+
s.portals = { w: [parseSquare("d3")], b: [], max: 1 };
84+
const moves = legalMovesFrom(s, parseSquare("d3")).filter((m) => m.isPortalEntry);
9185
expect(moves.length).toBeGreaterThan(0);
92-
// d3 is a light square ((3+2)%2=1 -> light). All teleport targets must be light.
86+
// d3 is light: (3+2)%2=1.
9387
for (const m of moves) {
94-
expect(m.portalTo).toBeDefined();
95-
const t = m.portalTo!;
96-
expect((t.file + t.rank) % 2).toBe(1); // light
88+
expect((m.to.file + m.to.rank) % 2).toBe(1);
9789
}
9890
});
9991

10092
it("Teleport adjacency rule rejects targets next to any piece (when enabled)", () => {
101-
// White king a1, white knight h4 (can reach portal at f3), pawn at b3,
102-
// black king h8 (far). Adjacency rule on -> teleport target adjacent to
103-
// b3 must be excluded; far empty squares like e6 must be allowed.
104-
const s = asPortal(parseFEN("7k/8/8/8/7N/1P6/8/K7 w - - 0 1"));
105-
s.portals = { w: [], b: [parseSquare("f3")], max: 1 };
93+
const s = asPortal(parseFEN("7k/8/8/8/8/1P6/8/K7 w - - 0 1"));
94+
s.board[2][5] = { type: "N", color: "w" }; // Nf3 on its own portal
95+
s.portals = { w: [parseSquare("f3")], b: [], max: 1 };
10696
s.portalAdjacencyRule = true;
107-
const moves = legalMovesFrom(s, parseSquare("h4"))
108-
.filter((m) => sqEq(m.to, parseSquare("f3")) && m.portalTo);
109-
const targets = moves.map((m) => m.portalTo!);
110-
// Squares adjacent to b3 (and not to any other piece) -> excluded.
97+
const moves = legalMovesFrom(s, parseSquare("f3")).filter((m) => m.isPortalEntry);
98+
const targets = moves.map((m) => m.to);
11199
for (const name of ["a2", "a4", "b2", "b4", "c2", "c4"]) {
112100
expect(targets.some((t) => sqEq(t, parseSquare(name)))).toBe(false);
113101
}
114-
// A far empty square (no adjacent piece) is allowed.
115102
expect(targets.some((t) => sqEq(t, parseSquare("e6")))).toBe(true);
116103
});
117104

118105
it("Adjacency rule is bypassed when the teleport delivers check", () => {
119-
// Black king e8, black pawn e7, white knight h6, portal at g4,
120-
// white king a1. Knight Nh6-g4 lands on portal; teleport target f6 is
121-
// adjacent to the e7 pawn (forbidden by adjacency rule) but f6 delivers
122-
// a knight check on e8, so the move IS legal.
123-
const s = asPortal(parseFEN("4k3/4p3/7N/8/8/8/8/K7 w - - 0 1"));
124-
s.portals = { w: [], b: [parseSquare("g4")], max: 1 };
106+
const s = asPortal(parseFEN("4k3/4p3/8/8/8/8/8/K7 w - - 0 1"));
107+
s.board[3][6] = { type: "N", color: "w" }; // Ng4 on its own portal
108+
s.portals = { w: [parseSquare("g4")], b: [], max: 1 };
125109
s.portalAdjacencyRule = true;
126-
const moves = legalMovesFrom(s, parseSquare("h6"))
127-
.filter((m) => sqEq(m.to, parseSquare("g4")) && m.portalTo
128-
&& sqEq(m.portalTo, parseSquare("f6")));
110+
const moves = legalMovesFrom(s, parseSquare("g4"))
111+
.filter((m) => m.isPortalEntry && sqEq(m.to, parseSquare("f6")));
129112
expect(moves.length).toBe(1);
130113
});
131114

132115
it("With adjacency rule OFF (default), targets next to other pieces are allowed", () => {
133-
const s = asPortal(parseFEN("8/8/8/8/8/1P6/8/6N1 w - - 0 1"));
134-
s.portals = { w: [], b: [parseSquare("f3")], max: 1 };
135-
const targets = teleportTargets(s, parseSquare("g1"), parseSquare("f3"), { type: "N", color: "w" });
136-
// c2 is adjacent to b3; with the rule off it's permitted.
116+
const s = asPortal(parseFEN("8/8/8/8/8/1P6/8/8 w - - 0 1"));
117+
s.board[2][5] = { type: "N", color: "w" }; // Nf3 on its own portal
118+
s.portals = { w: [parseSquare("f3")], b: [], max: 1 };
119+
const targets = teleportTargets(s, parseSquare("f3"), parseSquare("f3"), { type: "N", color: "w" });
137120
expect(targets.some((t) => sqEq(t, parseSquare("c2")))).toBe(true);
138121
});
139122

140123
it("Teleport that leaves own king in check is illegal", () => {
141-
// White king on e1, white knight on g1, black rook on e8 pinning along e-file.
142-
// Portal at f3 lets the knight teleport, but moving the knight to a non-e-file
143-
// square is fine — the pin doesn't apply because the knight isn't blocking.
144-
// Construct a real pin: White K e1, white N e2 (blocks check from black R e8),
145-
// portal at f3 (offered by enemy). Knight Ne2 -> ... wait, Ne2 cannot reach f3
146-
// in one move. Use a rook scenario.
147-
// Easier: White K a1, white R a2 (only piece blocking black R a8). White R
148-
// moving anywhere off the a-file would expose the king. Add portal at b2.
149-
// Rook can move a2->b2 (which lands on portal). Teleport must not leave the
150-
// king exposed -> all teleport targets are illegal because moving the rook
151-
// off the a-file uncovers check.
152-
const s = asPortal(parseFEN("r6k/8/8/8/8/8/R7/K7 w - - 0 1"));
153-
s.portals = { w: [], b: [parseSquare("b2")], max: 1 };
154-
const moves = legalMovesFrom(s, parseSquare("a2"))
155-
.filter((m) => sqEq(m.to, parseSquare("b2")));
156-
// Any legal teleport must keep the rook on the a-file (file 0) so the
157-
// black rook on a8 is still blocked from giving check.
124+
// Black rook a8 pins the white rook against the king on a1.
125+
const s = asPortal(parseFEN("r6k/8/8/8/8/8/8/K7 w - - 0 1"));
126+
s.board[1][0] = { type: "R", color: "w" }; // Ra2 on its own portal
127+
s.portals = { w: [parseSquare("a2")], b: [], max: 1 };
128+
const moves = legalMovesFrom(s, parseSquare("a2")).filter((m) => m.isPortalEntry);
158129
expect(moves.length).toBeGreaterThan(0);
159130
for (const m of moves) {
160-
expect(m.portalTo).toBeDefined();
161-
expect(m.portalTo!.file).toBe(0);
131+
expect(m.to.file).toBe(0); // must stay on the a-file
162132
}
163133
});
164134
});
@@ -178,40 +148,25 @@ describe("Portal Chess: creator pass-through and capture-then-teleport", () => {
178148
expect(ns.portals?.w).toEqual([]);
179149
});
180150

181-
it("Knight captures a piece on a portal then teleports", () => {
182-
// White N at g1, black portal at f3 with a black pawn sitting on f3.
151+
it("Knight capturing a piece on its own portal lands normally and may teleport later", () => {
183152
const s = asPortal(parseFEN("8/8/8/8/8/5p2/8/6N1 w - - 0 1"));
184-
s.portals = { w: [], b: [parseSquare("f3")], max: 1 };
185-
// Pick a non-stay teleport variant so the portal is consumed.
186-
const moves = legalMovesFrom(s, parseSquare("g1"))
187-
.filter((m) => sqEq(m.to, parseSquare("f3"))
188-
&& m.portalTo && !sqEq(m.portalTo, parseSquare("f3")));
189-
expect(moves.length).toBeGreaterThan(0);
190-
expect(moves[0].captured).toBe("P");
191-
expect(moves[0].isPortalEntry).toBe(true);
192-
const ns = makeMove(s, moves[0]);
193-
// Portal consumed.
194-
expect(ns.portals?.b).toEqual([]);
195-
// Knight is at portalTo, NOT at f3.
196-
expect(ns.board[2][5]).toBeNull();
197-
expect(ns.board[moves[0].portalTo!.rank][moves[0].portalTo!.file]).toEqual({ type: "N", color: "w" });
198-
// The captured pawn is gone.
199-
let pawnCount = 0;
200-
for (const row of ns.board) for (const p of row) if (p?.type === "P" && p.color === "b") pawnCount++;
201-
expect(pawnCount).toBe(0);
153+
s.portals = { w: [parseSquare("f3")], b: [], max: 1 };
154+
const cap = legalMovesFrom(s, parseSquare("g1")).filter((m) => sqEq(m.to, parseSquare("f3")));
155+
expect(cap.length).toBe(1);
156+
expect(cap[0].isPortalEntry).toBeFalsy();
157+
expect(cap[0].captured).toBe("P");
158+
const ns = makeMove(s, cap[0]);
159+
expect(ns.board[2][5]).toEqual({ type: "N", color: "w" });
160+
expect(ns.portals?.w).toEqual([parseSquare("f3")]);
202161
});
203162

204-
it("Stay-in-place teleport: piece can choose portal square as target; portal stays active", () => {
205-
const s = asPortal(parseFEN("8/8/8/8/8/8/8/6N1 w - - 0 1"));
206-
s.portals = { w: [], b: [parseSquare("f3")], max: 1 };
207-
const stayMove = legalMovesFrom(s, parseSquare("g1"))
208-
.find((m) => m.isPortalEntry && m.portalTo && sqEq(m.portalTo, parseSquare("f3")));
209-
expect(stayMove).toBeDefined();
210-
const ns = makeMove(s, stayMove!);
211-
// Knight ends on f3 (the portal square).
212-
expect(ns.board[2][5]).toEqual({ type: "N", color: "w" });
213-
// Portal remains active.
214-
expect(ns.portals?.b).toEqual([parseSquare("f3")]);
163+
it("Piece on portal cannot teleport to its own portal square (must move off)", () => {
164+
const s = asPortal(parseFEN("8/8/8/8/8/8/8/8 w - - 0 1"));
165+
s.board[2][5] = { type: "N", color: "w" };
166+
s.portals = { w: [parseSquare("f3")], b: [], max: 1 };
167+
const stay = legalMovesFrom(s, parseSquare("f3"))
168+
.find((m) => m.isPortalEntry && sqEq(m.to, parseSquare("f3")));
169+
expect(stay).toBeUndefined();
215170
});
216171
});
217172

@@ -240,14 +195,14 @@ describe("Portal Chess: creator life", () => {
240195
});
241196

242197
describe("Portal Chess: legal-move enumeration", () => {
243-
it("allLegalMoves expands portal entries into per-target moves", () => {
244-
const s = asPortal(parseFEN("8/8/8/8/8/8/8/6N1 w - - 0 1"));
245-
s.portals = { w: [], b: [parseSquare("f3")], max: 1 };
198+
it("allLegalMoves includes per-target teleport moves from a piece on its portal", () => {
199+
const s = asPortal(parseFEN("8/8/8/8/8/8/8/8 w - - 0 1"));
200+
s.board[2][5] = { type: "N", color: "w" };
201+
s.portals = { w: [parseSquare("f3")], b: [], max: 1 };
246202
const moves = allLegalMoves(s);
247-
const onPortal = moves.filter((m) => sqEq(m.to, parseSquare("f3")));
248-
// Multiple teleport targets => multiple legal moves landing on f3.
249-
expect(onPortal.length).toBeGreaterThan(1);
250-
expect(onPortal.every((m) => m.isPortalEntry && m.portalTo)).toBe(true);
203+
const tele = moves.filter((m) => m.isPortalEntry && sqEq(m.from, parseSquare("f3")));
204+
expect(tele.length).toBeGreaterThan(1);
205+
expect(tele.every((m) => m.portalTo)).toBe(true);
251206
});
252207

253208
it("Pawn landing on a portal is a single regular move (no teleport variants)", () => {

0 commit comments

Comments
 (0)