Skip to content

Commit b07d3fc

Browse files
committed
Portal Chess: default creator to Knight; add 1/2/3 active portals per player option
1 parent f95bfe6 commit b07d3fc

8 files changed

Lines changed: 92 additions & 61 deletions

File tree

src/GameContext.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ import {
2121
type Mode =
2222
| { kind: "two-player" }
2323
| { kind: "bot"; level: number }
24-
| { kind: "portal"; opponent: "two-player" | { kind: "bot"; level: number }; creator: PieceType; adjacencyRule?: boolean };
24+
| { kind: "portal"; opponent: "two-player" | { kind: "bot"; level: number }; creator: PieceType; adjacencyRule?: boolean; portalMax?: number };
2525
export interface Players { w: string; b: string; }
2626

2727
/** Build the initial state for Portal Chess (creator-type portals). */
28-
function portalInitialState(creator: PieceType, adjacencyRule = false): GameState {
28+
function portalInitialState(creator: PieceType, adjacencyRule = false, portalMax = 1): GameState {
2929
const s = initialState();
30-
s.portals = { w: null, b: null };
30+
s.portals = { w: [], b: [], max: portalMax };
3131
s.portalCreators = { w: creator, b: creator };
3232
s.portalAdjacencyRule = adjacencyRule;
3333
return s;
@@ -368,7 +368,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
368368
? `Bot Lv ${m.opponent.level}`
369369
: "Player 2";
370370
setPlayers({ w: p?.w ?? defaultW, b: p?.b ?? defaultB });
371-
const fresh = m.kind === "portal" ? portalInitialState(m.creator, m.adjacencyRule === true) : initialState();
371+
const fresh = m.kind === "portal" ? portalInitialState(m.creator, m.adjacencyRule === true, m.portalMax ?? 1) : initialState();
372372
dispatch({ type: "new", initial: fresh });
373373
setSelected(null);
374374
setPaused(false);

src/components/Board.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ export function Board({ flipped = false }: Props) {
8282
const checkedKing = result.kind === "ongoing" && inCheck(state, state.turn)
8383
? findKing(state, state.turn)
8484
: null;
85-
const wPortal = state.portals?.w ?? null;
86-
const bPortal = state.portals?.b ?? null;
85+
const wPortals = state.portals?.w ?? [];
86+
const bPortals = state.portals?.b ?? [];
8787
const isTeleportMove =
8888
!!lastMove?.isPortalEntry &&
8989
!!lastMove.portalTo &&
@@ -107,8 +107,8 @@ export function Board({ flipped = false }: Props) {
107107
const isChecked = checkedKing && checkedKing.file === f && checkedKing.rank === r;
108108
const isHintFrom = hint && hint.from.file === f && hint.from.rank === r;
109109
const isHintTo = hint && hint.to.file === f && hint.to.rank === r;
110-
const isPortalW = wPortal && wPortal.file === f && wPortal.rank === r;
111-
const isPortalB = bPortal && bPortal.file === f && bPortal.rank === r;
110+
const isPortalW = wPortals.some((p) => p.file === f && p.rank === r);
111+
const isPortalB = bPortals.some((p) => p.file === f && p.rank === r);
112112
const isTeleportTarget =
113113
portalChoice && portalChoice.targets.some(
114114
(m) => m.portalTo && m.portalTo.file === f && m.portalTo.rank === r

src/engine/__tests__/portal.test.ts

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { allLegalMoves, legalMovesFrom, makeMove, teleportTargets } from "../rul
44

55
/** Wrap a state into Portal Chess mode with given creator type. */
66
function asPortal(s: GameState, creator: "Q" | "R" | "B" | "N" | "K" = "Q"): GameState {
7-
s.portals = { w: null, b: null };
7+
s.portals = { w: [], b: [], max: 1 };
88
s.portalCreators = { w: creator, b: creator };
99
return s;
1010
}
@@ -29,10 +29,10 @@ describe("Portal Chess: portal creation", () => {
2929
it("Queen drops a portal under herself on her first move", () => {
3030
let s = asPortal(initialState());
3131
s = play(s, "d2", "d3"); // pawn move, no portal
32-
expect(s.portals?.w).toBeNull();
32+
expect(s.portals?.w).toEqual([]);
3333
s = play(s, "e7", "e6"); // black pawn
3434
s = play(s, "d1", "d2"); // Qd2 legal
35-
expect(s.portals?.w).toEqual({ file: 3, rank: 1 });
35+
expect(s.portals?.w).toEqual([{ file: 3, rank: 1 }]);
3636
});
3737

3838
it("Queen does NOT drop a second portal while one is active", () => {
@@ -42,18 +42,18 @@ describe("Portal Chess: portal creation", () => {
4242
s = play(s, "d1", "d2"); // first portal at d2
4343
s = play(s, "e6", "e5"); // black tempo
4444
s = play(s, "d2", "e3"); // queen moves; no new portal because one is active
45-
expect(s.portals?.w).toEqual({ file: 3, rank: 1 }); // still at d2
45+
expect(s.portals?.w).toEqual([{ file: 3, rank: 1 }]); // still at d2
4646
});
4747

4848
it("Pawn landing on a portal does not consume it and does not teleport", () => {
4949
// Custom position: white pawn at e4, black portal at e5, black to move skipped.
5050
const s = asPortal(parseFEN("8/8/8/4p3/4P3/8/8/8 w - - 0 1"));
5151
// Manually place a black portal at e5 via state hack (no creator move yet).
52-
s.portals = { w: null, b: parseSquare("e5") };
52+
s.portals = { w: [], b: [parseSquare("e5")], max: 1 };
5353
s.turn = "w";
5454
// White pawn at e4 cannot capture e5 (same file). Switch to a portal at d5
5555
// so the pawn captures into it.
56-
s.portals = { w: null, b: parseSquare("d5") };
56+
s.portals = { w: [], b: [parseSquare("d5")], max: 1 };
5757
s.board[4][3] = { type: "P", color: "b" };
5858
const moves = legalMovesFrom(s, parseSquare("e4"));
5959
const cap = moves.filter((m) => m.to.file === 3 && m.to.rank === 4);
@@ -62,15 +62,15 @@ describe("Portal Chess: portal creation", () => {
6262
expect(cap.every((m) => !m.isPortalEntry)).toBe(true);
6363
// The portal also persists after the pawn move.
6464
const ns = makeMove(s, cap[0]);
65-
expect(ns.portals?.b).toEqual(parseSquare("d5"));
65+
expect(ns.portals?.b).toEqual([parseSquare("d5")]);
6666
});
6767
});
6868

6969
describe("Portal Chess: teleport entry", () => {
7070
it("Knight landing on a portal is forced to teleport", () => {
7171
// Place a black portal at f3, white knight on g1, no other obstructions.
7272
const s = asPortal(parseFEN("8/8/8/8/8/8/8/6N1 w - - 0 1"));
73-
s.portals = { w: null, b: parseSquare("f3") };
73+
s.portals = { w: [], b: [parseSquare("f3")], max: 1 };
7474
const moves = legalMovesFrom(s, parseSquare("g1"));
7575
const fMoves = moves.filter((m) => m.to.file === 5 && m.to.rank === 2);
7676
expect(fMoves.length).toBeGreaterThan(0);
@@ -81,10 +81,10 @@ describe("Portal Chess: teleport entry", () => {
8181
it("Bishop teleport is restricted to same-colour squares as the portal", () => {
8282
// Place a portal at e4 (light square). Bishop must teleport to a light square only.
8383
const s = asPortal(parseFEN("8/8/8/8/8/8/8/4B3 w - - 0 1"));
84-
s.portals = { w: null, b: parseSquare("e4") };
84+
s.portals = { w: [], b: [parseSquare("e4")], max: 1 };
8585
// First move bishop e1 -> e4? bishops can't move file-only. Use diagonal.
8686
// Place bishop at h1 and portal at a8 (both light)? Let's pick portal at d3 (dark).
87-
s.portals = { w: null, b: parseSquare("d3") };
87+
s.portals = { w: [], b: [parseSquare("d3")], max: 1 };
8888
s.board[0][4] = null;
8989
s.board[0][5] = { type: "B", color: "w" }; // Bf1
9090
const moves = legalMovesFrom(s, parseSquare("f1")).filter((m) => sqEq(m.to, parseSquare("d3")));
@@ -102,7 +102,7 @@ describe("Portal Chess: teleport entry", () => {
102102
// black king h8 (far). Adjacency rule on -> teleport target adjacent to
103103
// b3 must be excluded; far empty squares like e6 must be allowed.
104104
const s = asPortal(parseFEN("7k/8/8/8/7N/1P6/8/K7 w - - 0 1"));
105-
s.portals = { w: null, b: parseSquare("f3") };
105+
s.portals = { w: [], b: [parseSquare("f3")], max: 1 };
106106
s.portalAdjacencyRule = true;
107107
const moves = legalMovesFrom(s, parseSquare("h4"))
108108
.filter((m) => sqEq(m.to, parseSquare("f3")) && m.portalTo);
@@ -121,7 +121,7 @@ describe("Portal Chess: teleport entry", () => {
121121
// adjacent to the e7 pawn (forbidden by adjacency rule) but f6 delivers
122122
// a knight check on e8, so the move IS legal.
123123
const s = asPortal(parseFEN("4k3/4p3/7N/8/8/8/8/K7 w - - 0 1"));
124-
s.portals = { w: null, b: parseSquare("g4") };
124+
s.portals = { w: [], b: [parseSquare("g4")], max: 1 };
125125
s.portalAdjacencyRule = true;
126126
const moves = legalMovesFrom(s, parseSquare("h6"))
127127
.filter((m) => sqEq(m.to, parseSquare("g4")) && m.portalTo
@@ -131,7 +131,7 @@ describe("Portal Chess: teleport entry", () => {
131131

132132
it("With adjacency rule OFF (default), targets next to other pieces are allowed", () => {
133133
const s = asPortal(parseFEN("8/8/8/8/8/1P6/8/6N1 w - - 0 1"));
134-
s.portals = { w: null, b: parseSquare("f3") };
134+
s.portals = { w: [], b: [parseSquare("f3")], max: 1 };
135135
const targets = teleportTargets(s, parseSquare("g1"), parseSquare("f3"), { type: "N", color: "w" });
136136
// c2 is adjacent to b3; with the rule off it's permitted.
137137
expect(targets.some((t) => sqEq(t, parseSquare("c2")))).toBe(true);
@@ -150,7 +150,7 @@ describe("Portal Chess: teleport entry", () => {
150150
// king exposed -> all teleport targets are illegal because moving the rook
151151
// off the a-file uncovers check.
152152
const s = asPortal(parseFEN("r6k/8/8/8/8/8/R7/K7 w - - 0 1"));
153-
s.portals = { w: null, b: parseSquare("b2") };
153+
s.portals = { w: [], b: [parseSquare("b2")], max: 1 };
154154
const moves = legalMovesFrom(s, parseSquare("a2"))
155155
.filter((m) => sqEq(m.to, parseSquare("b2")));
156156
// Any legal teleport must keep the rook on the a-file (file 0) so the
@@ -167,21 +167,21 @@ describe("Portal Chess: creator pass-through and capture-then-teleport", () => {
167167
it("The creator (Queen) does not teleport when she lands on a portal", () => {
168168
// Place black portal at e4, white queen at e1, clear file.
169169
const s = asPortal(parseFEN("8/8/8/8/8/8/8/4Q3 w - - 0 1"));
170-
s.portals = { w: null, b: parseSquare("e4") };
170+
s.portals = { w: [], b: [parseSquare("e4")], max: 1 };
171171
const moves = legalMovesFrom(s, parseSquare("e1")).filter((m) => sqEq(m.to, parseSquare("e4")));
172172
expect(moves.length).toBe(1);
173173
expect(moves[0].isPortalEntry).toBeFalsy();
174174
// After the move: enemy portal STILL active (queen passed through).
175175
const ns = makeMove(s, moves[0]);
176-
expect(ns.portals?.b).toEqual(parseSquare("e4"));
176+
expect(ns.portals?.b).toEqual([parseSquare("e4")]);
177177
// No new white portal because there's already a portal at her landing square.
178-
expect(ns.portals?.w).toBeNull();
178+
expect(ns.portals?.w).toEqual([]);
179179
});
180180

181181
it("Knight captures a piece on a portal then teleports", () => {
182182
// White N at g1, black portal at f3 with a black pawn sitting on f3.
183183
const s = asPortal(parseFEN("8/8/8/8/8/5p2/8/6N1 w - - 0 1"));
184-
s.portals = { w: null, b: parseSquare("f3") };
184+
s.portals = { w: [], b: [parseSquare("f3")], max: 1 };
185185
// Pick a non-stay teleport variant so the portal is consumed.
186186
const moves = legalMovesFrom(s, parseSquare("g1"))
187187
.filter((m) => sqEq(m.to, parseSquare("f3"))
@@ -191,7 +191,7 @@ describe("Portal Chess: creator pass-through and capture-then-teleport", () => {
191191
expect(moves[0].isPortalEntry).toBe(true);
192192
const ns = makeMove(s, moves[0]);
193193
// Portal consumed.
194-
expect(ns.portals?.b).toBeNull();
194+
expect(ns.portals?.b).toEqual([]);
195195
// Knight is at portalTo, NOT at f3.
196196
expect(ns.board[2][5]).toBeNull();
197197
expect(ns.board[moves[0].portalTo!.rank][moves[0].portalTo!.file]).toEqual({ type: "N", color: "w" });
@@ -203,15 +203,15 @@ describe("Portal Chess: creator pass-through and capture-then-teleport", () => {
203203

204204
it("Stay-in-place teleport: piece can choose portal square as target; portal stays active", () => {
205205
const s = asPortal(parseFEN("8/8/8/8/8/8/8/6N1 w - - 0 1"));
206-
s.portals = { w: null, b: parseSquare("f3") };
206+
s.portals = { w: [], b: [parseSquare("f3")], max: 1 };
207207
const stayMove = legalMovesFrom(s, parseSquare("g1"))
208208
.find((m) => m.isPortalEntry && m.portalTo && sqEq(m.portalTo, parseSquare("f3")));
209209
expect(stayMove).toBeDefined();
210210
const ns = makeMove(s, stayMove!);
211211
// Knight ends on f3 (the portal square).
212212
expect(ns.board[2][5]).toEqual({ type: "N", color: "w" });
213213
// Portal remains active.
214-
expect(ns.portals?.b).toEqual(parseSquare("f3"));
214+
expect(ns.portals?.b).toEqual([parseSquare("f3")]);
215215
});
216216
});
217217

@@ -220,7 +220,7 @@ describe("Portal Chess: creator life", () => {
220220
// White Q at d1, black R at d8. Black plays Rxd1 (captures the white Q).
221221
const s = asPortal(parseFEN("3r4/8/8/8/8/8/8/3Q4 b - - 0 1"));
222222
const ns = play(s, "d8", "d1");
223-
expect(ns.portals?.w).toBeNull(); // no white portal placed by capture
223+
expect(ns.portals?.w).toEqual([]); // no white portal placed by capture
224224
// Confirm white has no Q left, so subsequent white moves don't drop portals.
225225
let wQ = 0;
226226
for (const row of ns.board) for (const p of row) if (p?.type === "Q" && p.color === "w") wQ++;
@@ -235,14 +235,14 @@ describe("Portal Chess: creator life", () => {
235235
expect(moves.length).toBe(1);
236236
const ns = makeMove(s, moves[0]);
237237
// The promoted Q just moved -> portal drops at a8.
238-
expect(ns.portals?.w).toEqual(parseSquare("a8"));
238+
expect(ns.portals?.w).toEqual([parseSquare("a8")]);
239239
});
240240
});
241241

242242
describe("Portal Chess: legal-move enumeration", () => {
243243
it("allLegalMoves expands portal entries into per-target moves", () => {
244244
const s = asPortal(parseFEN("8/8/8/8/8/8/8/6N1 w - - 0 1"));
245-
s.portals = { w: null, b: parseSquare("f3") };
245+
s.portals = { w: [], b: [parseSquare("f3")], max: 1 };
246246
const moves = allLegalMoves(s);
247247
const onPortal = moves.filter((m) => sqEq(m.to, parseSquare("f3")));
248248
// Multiple teleport targets => multiple legal moves landing on f3.
@@ -252,7 +252,7 @@ describe("Portal Chess: legal-move enumeration", () => {
252252

253253
it("Pawn landing on a portal is a single regular move (no teleport variants)", () => {
254254
const s = asPortal(parseFEN("8/8/8/8/3p4/2P5/8/8 w - - 0 1"));
255-
s.portals = { w: null, b: parseSquare("d4") };
255+
s.portals = { w: [], b: [parseSquare("d4")], max: 1 };
256256
const moves = legalMovesFrom(s, parseSquare("c3")).filter((m) => sqEq(m.to, parseSquare("d4")));
257257
expect(moves.length).toBe(1);
258258
expect(moves[0].isPortalEntry).toBeFalsy();
@@ -264,8 +264,10 @@ describe("Portal Chess: position keys differ by portal location", () => {
264264
it("Same board with different portal positions yields different keys", () => {
265265
const s1 = asPortal(initialState());
266266
const s2 = asPortal(initialState());
267-
s1.portals = { w: parseSquare("d4"), b: null };
268-
s2.portals = { w: parseSquare("e4"), b: null };
267+
s1.portals = { w: [parseSquare("d4")], b: [], max: 1 };
268+
s2.portals = { w: [parseSquare("e4")], b: [], max: 1 };
269269
expect(positionKey(s1)).not.toBe(positionKey(s2));
270270
});
271271
});
272+
273+

src/engine/board.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,11 @@ export interface GameState {
4747
// Rolling list of position keys for threefold-repetition detection.
4848
positionKeys: string[];
4949
/**
50-
* Portal Chess: per-side active portal location (or null for none).
51-
* Undefined disables portal mode entirely (default for normal chess).
50+
* Portal Chess: per-side active portal locations (0..max). Empty array =
51+
* no active portals for that side. Undefined `portals` disables portal
52+
* mode entirely (default for normal chess).
5253
*/
53-
portals?: { w: Square | null; b: Square | null };
54+
portals?: { w: Square[]; b: Square[]; max: number };
5455
/**
5556
* Portal Chess: which piece type creates portals for each side.
5657
* Set when a Portal Chess game starts. Undefined = mode off.
@@ -98,8 +99,9 @@ export function cloneState(s: GameState): GameState {
9899
positionKeys: (s.positionKeys ?? []).slice(),
99100
portals: s.portals
100101
? {
101-
w: s.portals.w ? { ...s.portals.w } : null,
102-
b: s.portals.b ? { ...s.portals.b } : null
102+
w: s.portals.w.map((p) => ({ ...p })),
103+
b: s.portals.b.map((p) => ({ ...p })),
104+
max: s.portals.max
103105
}
104106
: undefined,
105107
portalCreators: s.portalCreators ? { ...s.portalCreators } : undefined,
@@ -153,9 +155,14 @@ export function positionKey(s: GameState): string {
153155
const ep = s.enPassant ? `${s.enPassant.file}${s.enPassant.rank}` : "-";
154156
let portalKey = "";
155157
if (s.portals) {
156-
const wp = s.portals.w ? `${s.portals.w.file}${s.portals.w.rank}` : "-";
157-
const bp = s.portals.b ? `${s.portals.b.file}${s.portals.b.rank}` : "-";
158-
portalKey = `|P${wp}${bp}`;
158+
const enc = (arr: Square[]) =>
159+
arr.length === 0
160+
? "-"
161+
: arr
162+
.map((p) => `${p.file}${p.rank}`)
163+
.sort()
164+
.join(",");
165+
portalKey = `|P${enc(s.portals.w)};${enc(s.portals.b)}`;
159166
}
160167
return `${b}|${s.turn}|${c}|${ep}${portalKey}`;
161168
}

src/engine/rules.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ const KING_DIRS: Dir[] = [...BISHOP_DIRS, ...ROOK_DIRS];
2626
/** Portal Chess: returns "w"/"b" if `sq` hosts an active portal, else null. */
2727
export function portalAt(state: GameState, sq: Square): Color | null {
2828
if (!state.portals) return null;
29-
if (state.portals.w && sqEq(state.portals.w, sq)) return "w";
30-
if (state.portals.b && sqEq(state.portals.b, sq)) return "b";
29+
if (state.portals.w.some((p) => sqEq(p, sq))) return "w";
30+
if (state.portals.b.some((p) => sqEq(p, sq))) return "b";
3131
return null;
3232
}
3333

@@ -314,7 +314,9 @@ export function makeMove(state: GameState, move: Move): GameState {
314314
const stayed = sqEq(move.portalTo, move.to);
315315
if (!stayed) {
316316
const owner = portalAt(ns, move.to);
317-
if (owner) ns.portals[owner] = null;
317+
if (owner) {
318+
ns.portals[owner] = ns.portals[owner].filter((p) => !sqEq(p, move.to));
319+
}
318320
}
319321
landing = move.portalTo;
320322
}
@@ -326,19 +328,17 @@ export function makeMove(state: GameState, move: Move): GameState {
326328
ns.board[landing.rank][landing.file] = placed;
327329

328330
// Portal Chess: auto-drop a portal under the creator piece if her side has
329-
// no active portal and her landing square has no portal currently. Skipped
330-
// for portal entries (creator can't trigger teleport, so this is unreachable
331-
// there in practice, but guard anyway) and skipped if the move was a pawn
332-
// promotion that produced a non-creator piece.
331+
// fewer than the max active portals and her landing square has no portal
332+
// currently. Skipped for portal entries (creator can't trigger teleport).
333333
if (
334334
ns.portals &&
335335
ns.portalCreators &&
336336
!move.isPortalEntry &&
337337
placed.type === ns.portalCreators[piece.color] &&
338-
ns.portals[piece.color] === null &&
338+
ns.portals[piece.color].length < ns.portals.max &&
339339
portalAt(ns, landing) === null
340340
) {
341-
ns.portals[piece.color] = { ...landing };
341+
ns.portals[piece.color] = [...ns.portals[piece.color], { ...landing }];
342342
}
343343

344344
// Update castling rights

src/puzzles/portal-puzzle-db.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ interface RawCandidate {
5050
function setupState(c: RawCandidate): GameState {
5151
const s = parseFEN(c.fen);
5252
s.portals = {
53-
w: c.wPortal ? alg(c.wPortal) : null,
54-
b: c.bPortal ? alg(c.bPortal) : null,
53+
w: c.wPortal ? [alg(c.wPortal)] : [],
54+
b: c.bPortal ? [alg(c.bPortal)] : [],
55+
max: 1,
5556
};
5657
s.portalCreators = { w: "K", b: "K" };
5758
return s;

src/puzzles/portal-puzzles.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ function makePortalPuzzle(p: PortalPuzzleRow): PortalPuzzle {
3535
setup(): GameState {
3636
const state = parseFEN(p.fen);
3737
state.portals = {
38-
w: p.wPortal ? alg(p.wPortal) : null,
39-
b: p.bPortal ? alg(p.bPortal) : null,
38+
w: p.wPortal ? [alg(p.wPortal)] : [],
39+
b: p.bPortal ? [alg(p.bPortal)] : [],
40+
max: 1,
4041
};
4142
state.portalCreators = { w: p.creator, b: p.creator };
4243
return state;

0 commit comments

Comments
 (0)