Skip to content

Commit ede9ce0

Browse files
committed
Add settings back nav and portal teleport safety
1 parent f9a1284 commit ede9ce0

9 files changed

Lines changed: 81 additions & 88 deletions

File tree

src/engine/__tests__/portal.test.ts

Lines changed: 17 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from "vitest";
22
import { GameState, initialState, parseFEN, parseSquare, positionKey, sqEq } from "../board";
3-
import { allLegalMoves, inCheck, legalMovesFrom, makeMove, teleportTargets } from "../rules";
3+
import { allLegalMoves, legalMovesFrom, makeMove, teleportTargets } from "../rules";
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 {
@@ -64,30 +64,6 @@ 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-
});
9167
});
9268

9369
describe("Portal Chess: teleport entry", () => {
@@ -136,18 +112,6 @@ describe("Portal Chess: teleport entry", () => {
136112
expect(moves.length).toBe(1);
137113
});
138114

139-
it("Teleport move can immediately give check", () => {
140-
const s = asPortal(parseFEN("4k3/4p3/8/8/8/8/8/K7 w - - 0 1"));
141-
s.board[3][6] = { type: "N", color: "w" }; // Ng4 on its own portal
142-
s.portals = { w: [parseSquare("g4")], b: [], max: 1 };
143-
const move = legalMovesFrom(s, parseSquare("g4"))
144-
.find((m) => m.isPortalEntry && sqEq(m.to, parseSquare("f6")));
145-
expect(move).toBeDefined();
146-
147-
const ns = makeMove(s, move!);
148-
expect(inCheck(ns, "b")).toBe(true);
149-
});
150-
151115
it("With adjacency rule OFF (default), targets next to other pieces are allowed", () => {
152116
const s = asPortal(parseFEN("8/8/8/8/8/1P6/8/8 w - - 0 1"));
153117
s.board[2][5] = { type: "N", color: "w" }; // Nf3 on its own portal
@@ -167,6 +131,22 @@ describe("Portal Chess: teleport entry", () => {
167131
expect(m.to.file).toBe(0); // must stay on the a-file
168132
}
169133
});
134+
135+
it("Cannot teleport into an attacked square unless that square is also a legal normal move", () => {
136+
// Black rook on d8 attacks d-file squares (including d3/d4).
137+
// White knight on f3 is on its own portal.
138+
const s = asPortal(parseFEN("3r3k/8/8/8/8/8/8/K7 w - - 0 1"));
139+
s.board[2][5] = { type: "N", color: "w" }; // Nf3
140+
s.portals = { w: [parseSquare("f3")], b: [], max: 1 };
141+
142+
const tele = legalMovesFrom(s, parseSquare("f3")).filter((m) => m.isPortalEntry);
143+
144+
// d3 is attacked by the rook but is NOT a legal normal knight destination.
145+
expect(tele.some((m) => sqEq(m.to, parseSquare("d3")))).toBe(false);
146+
147+
// d4 is attacked too, but IS a legal normal knight destination from f3.
148+
expect(tele.some((m) => sqEq(m.to, parseSquare("d4")))).toBe(true);
149+
});
170150
});
171151

172152
describe("Portal Chess: creator pass-through and capture-then-teleport", () => {

src/engine/rules.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,12 +303,13 @@ 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): 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.
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).
309310
if (ns.portals && ns.portalCreators) {
310311
const creator = ns.portalCreators[piece.color];
311-
if (piece.type !== "P" && piece.type !== creator) {
312+
if (piece.type !== creator) {
312313
const ownPortalAtFrom = ns.portals[piece.color].some((p) => sqEq(p, move.from));
313314
if (ownPortalAtFrom) {
314315
ns.portals[piece.color] = ns.portals[piece.color].filter((p) => !sqEq(p, move.from));
@@ -416,6 +417,13 @@ export function legalMovesFrom(state: GameState, from: Square): Move[] {
416417
if (!inCheck(ns, p.color)) out.push(m);
417418
}
418419

420+
// For portal safety rule: track legal non-portal destinations from this
421+
// square. Teleporting into an attacked square is only allowed when this
422+
// piece could already move to that destination without using a portal.
423+
const normalLegalTargets = new Set(
424+
out.map((m) => `${m.to.file},${m.to.rank}`)
425+
);
426+
419427
// Portal Chess (deferred warp): if this piece is currently sitting on its
420428
// own side's portal, it may also teleport to any empty square. Pawns and
421429
// the creator piece can't use portals.
@@ -438,6 +446,9 @@ export function legalMovesFrom(state: GameState, from: Square): Move[] {
438446
};
439447
const ns = makeMove(state, tpMove);
440448
if (inCheck(ns, p.color)) continue;
449+
const attackedAfterTeleport = isSquareAttacked(ns, t, opposite(p.color));
450+
const legalNormally = normalLegalTargets.has(`${t.file},${t.rank}`);
451+
if (attackedAfterTeleport && !legalNormally) continue;
441452
if (adjacency) {
442453
const adjacent = teleportIsAdjacentToPiece(state, t, from);
443454
if (adjacent && !inCheck(ns, opposite(p.color))) continue;

src/screens/GameScreen.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export function GameScreen() {
6262
<div className="topbar">
6363
<Link to="/">← Home</Link>
6464
<Link to="/new">New game</Link>
65+
<Link to="/settings">⚙ Settings</Link>
6566
</div>
6667
<Clock />
6768
<LookPicker />

src/screens/LeaderboardScreen.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ export function LeaderboardScreen() {
66
const sorted = store.profiles.slice().sort((a, b) => b.stats.rating - a.stats.rating);
77
return (
88
<div className="screen">
9-
<div className="topbar"><Link to="/">← Home</Link></div>
9+
<div className="topbar">
10+
<Link to="/">← Home</Link>
11+
<Link to="/settings">⚙ Settings</Link>
12+
</div>
1013
<h2>🏆 Leaderboard</h2>
1114
{sorted.length === 0 ? (
1215
<p>No players yet. <Link to="/profiles">Add one</Link>.</p>

src/screens/LearnScreen.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ export function LearnScreen() {
1919
};
2020
return (
2121
<div className="screen">
22-
<div className="topbar"><Link to="/">← Home</Link></div>
22+
<div className="topbar">
23+
<Link to="/">← Home</Link>
24+
<Link to="/settings">⚙ Settings</Link>
25+
</div>
2326
<h2>🧠 Learn</h2>
2427
<p>Tap a piece to hear how it moves. More interactive lessons coming soon!</p>
2528
<ul className="lessons">

src/screens/NewGameScreen.tsx

Lines changed: 15 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from "react";
1+
import { useState } from "react";
22
import { Link, useNavigate } from "react-router-dom";
33
import { PieceType } from "../engine/board";
44
import { useGame } from "../GameContext";
@@ -16,11 +16,6 @@ export function NewGameScreen() {
1616
const [portalOpponentKind, setPortalOpponentKind] = useState<"two-player" | "bot">("bot");
1717
const [portalAdjacencyRule, setPortalAdjacencyRule] = useState<boolean>(false);
1818
const [portalMax, setPortalMax] = useState<1 | 2 | 3>(1);
19-
const maxLevel = kind === "bot" ? 20 : 10;
20-
21-
useEffect(() => {
22-
if (level > maxLevel) setLevel(maxLevel);
23-
}, [level, maxLevel]);
2419

2520
const ensureProfile = (name: string): string => {
2621
const trimmed = name.trim();
@@ -34,30 +29,30 @@ export function NewGameScreen() {
3429
};
3530

3631
const start = () => {
32+
updateSetting("timerSeconds", timer);
33+
3734
// Ensure white profile exists and is active — stats are tied to this name
3835
const w = ensureProfile(whiteName || "Player 1");
3936
const wProf = store.profiles.find((p) => p.name.toLowerCase() === w.toLowerCase());
4037
if (wProf) setActiveProfile(wProf.id);
4138

4239
if (kind === "two-player") {
4340
const b = ensureProfile(blackName || "Player 2");
44-
newGame({ kind: "two-player" }, { w, b }, { timerSeconds: timer });
41+
newGame({ kind: "two-player" }, { w, b });
4542
} else if (kind === "bot") {
46-
newGame({ kind: "bot", level }, { w, b: `Bot Lv ${level}` }, { timerSeconds: timer });
43+
newGame({ kind: "bot", level }, { w, b: `Bot Lv ${level}` });
4744
} else {
4845
// Portal Chess
4946
if (portalOpponentKind === "two-player") {
5047
const b = ensureProfile(blackName || "Player 2");
5148
newGame(
5249
{ kind: "portal", opponent: "two-player", creator: portalCreator, adjacencyRule: portalAdjacencyRule, portalMax },
53-
{ w, b },
54-
{ timerSeconds: timer }
50+
{ w, b }
5551
);
5652
} else {
5753
newGame(
5854
{ kind: "portal", opponent: { kind: "bot", level }, creator: portalCreator, adjacencyRule: portalAdjacencyRule, portalMax },
59-
{ w, b: `Bot Lv ${level}` },
60-
{ timerSeconds: timer }
55+
{ w, b: `Bot Lv ${level}` }
6156
);
6257
}
6358
}
@@ -69,7 +64,10 @@ export function NewGameScreen() {
6964

7065
return (
7166
<div className="screen">
72-
<div className="topbar"><Link to="/">← Home</Link></div>
67+
<div className="topbar">
68+
<Link to="/">← Home</Link>
69+
<Link to="/settings">⚙ Settings</Link>
70+
</div>
7371
<h2>New Game</h2>
7472

7573
<section>
@@ -150,8 +148,8 @@ export function NewGameScreen() {
150148
</label>
151149
<p className="hint">
152150
When ticked, teleport targets cannot be adjacent to any other piece.
153-
When unticked (default), you can teleport to any empty square
154-
except the portal square itself.
151+
When unticked (default), you can teleport anywhere empty &mdash; or stay
152+
on the portal square (the portal remains active).
155153
</p>
156154
</section>
157155
</>
@@ -202,34 +200,11 @@ export function NewGameScreen() {
202200
</section>
203201
)}
204202

205-
{showBlackName && (
206-
<section>
207-
<h3>Board orientation (2-player)</h3>
208-
<label>
209-
<input
210-
type="checkbox"
211-
checked={store.settings.autoFlip}
212-
onChange={(e) => updateSetting("autoFlip", e.target.checked)}
213-
/>
214-
{" "}Auto-turn board after each move
215-
</label>
216-
<label>
217-
<input
218-
type="checkbox"
219-
checked={store.settings.rotateBlackPiecesFixedBoard}
220-
onChange={(e) => updateSetting("rotateBlackPiecesFixedBoard", e.target.checked)}
221-
/>
222-
{" "}Rotate black pieces 180° when board is fixed
223-
</label>
224-
<p className="hint">Turn off auto-turn to keep the board fixed from White's side. Optional: rotate black pieces for over-the-board seating.</p>
225-
</section>
226-
)}
227-
228203
{showLevel && (
229204
<section>
230205
<h3>Bot difficulty</h3>
231206
<div className="difficulty">
232-
{Array.from({ length: maxLevel }, (_, i) => i + 1).map((lv) => (
207+
{Array.from({ length: 10 }, (_, i) => i + 1).map((lv) => (
233208
<button
234209
key={lv}
235210
className={lv === level ? "pill active" : "pill"}
@@ -238,9 +213,7 @@ export function NewGameScreen() {
238213
))}
239214
</div>
240215
<p className="hint">
241-
{kind === "bot"
242-
? "Level 1 is very easy (good for a new learner). Levels 5-20 use an external chess engine for stronger play."
243-
: "Portal-bot mode uses the built-in engine and supports levels 1-10."}
216+
Level 1 is very easy (good for a new learner). Levels 6+ use a stronger engine when available.
244217
</p>
245218
</section>
246219
)}

src/screens/ProfileScreen.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ export function ProfileScreen() {
88

99
return (
1010
<div className="screen">
11-
<div className="topbar"><Link to="/">← Home</Link></div>
11+
<div className="topbar">
12+
<Link to="/">← Home</Link>
13+
<Link to="/settings">⚙ Settings</Link>
14+
</div>
1215
<h2>Players</h2>
1316
<div className="new-player">
1417
<input

src/screens/PuzzlesScreen.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export function PuzzlesScreen() {
182182
<div className="topbar">
183183
<Link to="/">← Home</Link>
184184
<span className="muted">Solved: {totalSolved} / {totalCount}</span>
185+
<Link to="/settings">⚙ Settings</Link>
185186
</div>
186187
<h2>🧩 Puzzles</h2>
187188

src/screens/SettingsScreen.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
1-
import { Link } from "react-router-dom";
1+
import { Link, useNavigate } from "react-router-dom";
22
import { useGame } from "../GameContext";
33

44
export function SettingsScreen() {
5+
const nav = useNavigate();
56
const { store, updateSetting } = useGame();
67
const s = store.settings;
8+
9+
const goBack = () => {
10+
if (window.history.length > 1) nav(-1);
11+
else nav("/");
12+
};
13+
714
return (
815
<div className="screen">
9-
<div className="topbar"><Link to="/">← Home</Link></div>
16+
<div className="topbar">
17+
<a
18+
href="#"
19+
onClick={(e) => {
20+
e.preventDefault();
21+
goBack();
22+
}}
23+
>
24+
← Back
25+
</a>
26+
<Link to="/">⌂ Home</Link>
27+
</div>
1028
<h2>⚙ Settings</h2>
1129

1230
<section>

0 commit comments

Comments
 (0)