Skip to content

Commit dedc0a5

Browse files
committed
Refine bot ladders and portal setup
1 parent ede9ce0 commit dedc0a5

9 files changed

Lines changed: 151 additions & 66 deletions

File tree

src/GameContext.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,19 @@ 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; portalMax?: number };
24+
| { kind: "portal"; opponent: "two-player" | { kind: "bot"; level: number }; creator: PieceType; portalMax?: number };
2525
export interface Players { w: string; b: string; }
2626

2727
interface NewGameOptions {
2828
timerSeconds?: number;
2929
}
3030

3131
/** Build the initial state for Portal Chess (creator-type portals). */
32-
function portalInitialState(creator: PieceType, adjacencyRule = false, portalMax = 1): GameState {
32+
function portalInitialState(creator: PieceType, portalMax = 2): GameState {
3333
const s = initialState();
3434
s.portals = { w: [], b: [], max: portalMax };
3535
s.portalCreators = { w: creator, b: creator };
36-
s.portalAdjacencyRule = adjacencyRule;
36+
s.portalAdjacencyRule = false;
3737
return s;
3838
}
3939

@@ -181,13 +181,13 @@ export function GameProvider({ children }: { children: ReactNode }) {
181181
} else if (lastMove?.isPortalEntry) {
182182
playSound("teleport");
183183
} else if (lastMove?.captured) {
184-
playSound("capture");
184+
if (!store.settings.explodeOnCapture) playSound("capture");
185185
} else {
186186
playSound("move");
187187
}
188188
}
189189
soundedLenRef.current = state.history.length;
190-
}, [state, result, mode, store.settings.sound]);
190+
}, [state, result, mode, store.settings.sound, store.settings.explodeOnCapture]);
191191

192192
// Persist active session so a refresh / PWA relaunch resumes the game.
193193
useEffect(() => {
@@ -389,7 +389,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
389389
? `Bot Lv ${m.opponent.level}`
390390
: "Player 2";
391391
setPlayers({ w: p?.w ?? defaultW, b: p?.b ?? defaultB });
392-
const fresh = m.kind === "portal" ? portalInitialState(m.creator, m.adjacencyRule === true, m.portalMax ?? 1) : initialState();
392+
const fresh = m.kind === "portal" ? portalInitialState(m.creator, m.portalMax ?? 2) : initialState();
393393
dispatch({ type: "new", initial: fresh });
394394
setSelected(null);
395395
setPaused(false);

src/components/Board.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CSSProperties, useLayoutEffect, useState } from "react";
22
import { Move, Piece as PieceT, Square, squareName } from "../engine/board";
33
import { findKing, inCheck } from "../engine/rules";
4+
import { playSound } from "../engine/sound";
45
import { useGame } from "../GameContext";
56
import { Piece } from "./Piece";
67

@@ -104,6 +105,7 @@ export function Board({ flipped = false }: Props) {
104105
setIsAnimatingLastMove(false);
105106
if (current.captured && store.settings.explodeOnCapture) {
106107
setShowCaptureBoom(true);
108+
playSound("boom", store.settings.sound);
107109
const boomId = window.setTimeout(() => setShowCaptureBoom(false), 620);
108110
return () => window.clearTimeout(boomId);
109111
}
@@ -116,6 +118,7 @@ export function Board({ flipped = false }: Props) {
116118
setIsAnimatingLastMove(false);
117119
if (current.captured && store.settings.explodeOnCapture) {
118120
setShowCaptureBoom(true);
121+
playSound("boom", store.settings.sound);
119122
boomId = window.setTimeout(() => setShowCaptureBoom(false), 620);
120123
}
121124
}, durationMs);

src/engine/__tests__/portal.test.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,14 @@ describe("Portal Chess: teleport entry", () => {
102102
expect(targets.some((t) => sqEq(t, parseSquare("e6")))).toBe(true);
103103
});
104104

105-
it("Adjacency rule is bypassed when the teleport delivers check", () => {
105+
it("Teleport into an attacked square is illegal even if it would give check", () => {
106106
const s = asPortal(parseFEN("4k3/4p3/8/8/8/8/8/K7 w - - 0 1"));
107107
s.board[3][6] = { type: "N", color: "w" }; // Ng4 on its own portal
108108
s.portals = { w: [parseSquare("g4")], b: [], max: 1 };
109109
s.portalAdjacencyRule = true;
110110
const moves = legalMovesFrom(s, parseSquare("g4"))
111111
.filter((m) => m.isPortalEntry && sqEq(m.to, parseSquare("f6")));
112-
expect(moves.length).toBe(1);
112+
expect(moves.length).toBe(0);
113113
});
114114

115115
it("With adjacency rule OFF (default), targets next to other pieces are allowed", () => {
@@ -120,19 +120,16 @@ describe("Portal Chess: teleport entry", () => {
120120
expect(targets.some((t) => sqEq(t, parseSquare("c2")))).toBe(true);
121121
});
122122

123-
it("Teleport that leaves own king in check is illegal", () => {
123+
it("Teleport options are empty when every destination is attacked or exposes king", () => {
124124
// Black rook a8 pins the white rook against the king on a1.
125125
const s = asPortal(parseFEN("r6k/8/8/8/8/8/8/K7 w - - 0 1"));
126126
s.board[1][0] = { type: "R", color: "w" }; // Ra2 on its own portal
127127
s.portals = { w: [parseSquare("a2")], b: [], max: 1 };
128128
const moves = legalMovesFrom(s, parseSquare("a2")).filter((m) => m.isPortalEntry);
129-
expect(moves.length).toBeGreaterThan(0);
130-
for (const m of moves) {
131-
expect(m.to.file).toBe(0); // must stay on the a-file
132-
}
129+
expect(moves.length).toBe(0);
133130
});
134131

135-
it("Cannot teleport into an attacked square unless that square is also a legal normal move", () => {
132+
it("Cannot teleport into an attacked square", () => {
136133
// Black rook on d8 attacks d-file squares (including d3/d4).
137134
// White knight on f3 is on its own portal.
138135
const s = asPortal(parseFEN("3r3k/8/8/8/8/8/8/K7 w - - 0 1"));
@@ -141,11 +138,9 @@ describe("Portal Chess: teleport entry", () => {
141138

142139
const tele = legalMovesFrom(s, parseSquare("f3")).filter((m) => m.isPortalEntry);
143140

144-
// d3 is attacked by the rook but is NOT a legal normal knight destination.
141+
// d3 and d4 are attacked by the rook, so both are illegal teleport targets.
145142
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);
143+
expect(tele.some((m) => sqEq(m.to, parseSquare("d4")))).toBe(false);
149144
});
150145
});
151146

src/engine/bot.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ interface BotOptions {
7070

7171
/**
7272
* Choose a move for the bot at a given difficulty (1-20).
73-
* Levels 1-4 use the built-in minimax with increasing depth and decreasing randomness.
74-
* Levels 5-20 can delegate to an external Stockfish API when enabled; if unavailable,
73+
* Levels 1-5 use the built-in minimax with increasing depth and decreasing randomness.
74+
* Levels 6-20 can delegate to an external Stockfish API when enabled; if unavailable,
7575
* fall back to local minimax.
7676
*/
7777
export async function chooseBotMove(state: GameState, level: number, opts?: BotOptions): Promise<Move | null> {
@@ -81,7 +81,7 @@ export async function chooseBotMove(state: GameState, level: number, opts?: BotO
8181
const normalizedLevel = Math.max(1, Math.min(20, Math.round(level)));
8282
const canUseExternal = opts?.allowExternal !== false && !state.portals;
8383

84-
if (canUseExternal && normalizedLevel >= 5) {
84+
if (canUseExternal && normalizedLevel >= 6) {
8585
try {
8686
const { stockfishBestMove } = await import("./bot/stockfish");
8787
const best = await stockfishBestMove(state, normalizedLevel);
@@ -97,7 +97,8 @@ export async function chooseBotMove(state: GameState, level: number, opts?: BotO
9797
// Level 2: ~50% random, depth-1 search the rest.
9898
// Level 3: depth-2, occasional blunder.
9999
// Level 4: depth-2 clean.
100-
// Level 5+: deterministic minimax fallback if external engine is unavailable.
100+
// Level 5: strongest local-only learn level.
101+
// Level 6+: deterministic minimax fallback if external engine is unavailable.
101102
const blunderChanceByLevel: Record<number, number> = { 1: 0.8, 2: 0.5, 3: 0.15, 4: 0.03 };
102103
const blunderChance = blunderChanceByLevel[normalizedLevel] ?? 0;
103104
const depth = normalizedLevel <= 2 ? 1 : normalizedLevel <= 4 ? 2 : normalizedLevel <= 8 ? 3 : 4;

src/engine/rules.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -417,13 +417,6 @@ export function legalMovesFrom(state: GameState, from: Square): Move[] {
417417
if (!inCheck(ns, p.color)) out.push(m);
418418
}
419419

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-
427420
// Portal Chess (deferred warp): if this piece is currently sitting on its
428421
// own side's portal, it may also teleport to any empty square. Pawns and
429422
// the creator piece can't use portals.
@@ -446,9 +439,7 @@ export function legalMovesFrom(state: GameState, from: Square): Move[] {
446439
};
447440
const ns = makeMove(state, tpMove);
448441
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;
442+
if (isSquareAttacked(ns, t, opposite(p.color))) continue;
452443
if (adjacency) {
453444
const adjacent = teleportIsAdjacentToPiece(state, t, from);
454445
if (adjacent && !inCheck(ns, opposite(p.color))) continue;

src/engine/sound.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Lightweight Web Audio synth for UI sounds — no external asset files.
22
// Each sound is a tiny oscillator burst so the PWA stays offline-friendly.
33

4-
type SoundName = "move" | "capture" | "check" | "win" | "draw" | "loss" | "teleport";
4+
type SoundName = "move" | "capture" | "boom" | "check" | "win" | "draw" | "loss" | "teleport";
55

66
let ctx: AudioContext | null = null;
77
function getCtx(): AudioContext | null {
@@ -36,6 +36,11 @@ export function playSound(name: SoundName, enabled = true) {
3636
try {
3737
if (name === "move") { tone(440, 70, "triangle", 0.12); }
3838
else if (name === "capture") { tone(180, 90, "square", 0.14); tone(110, 120, "sawtooth", 0.08, 30); }
39+
else if (name === "boom") {
40+
tone(82, 220, "sawtooth", 0.2);
41+
tone(64, 260, "square", 0.14, 20);
42+
tone(220, 120, "triangle", 0.07, 45);
43+
}
3944
else if (name === "check") { tone(880, 110, "sine", 0.15); tone(1175, 120, "sine", 0.13, 90); }
4045
else if (name === "win") { tone(523, 120, "triangle"); tone(659, 120, "triangle", 0.15, 120); tone(784, 180, "triangle", 0.18, 240); }
4146
else if (name === "loss") { tone(392, 140, "sawtooth", 0.14); tone(311, 220, "sawtooth", 0.14, 140); }

src/engine/storage.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ export interface Settings {
3939
autoFlip: boolean;
4040
showThreats: boolean;
4141
explodeOnCapture: boolean;
42+
portalCreatorDefault: PieceType;
43+
portalOpponentDefault: "two-player" | "bot";
44+
portalMaxDefault: 1 | 2 | 3;
4245
}
4346

4447
export interface SavedGame {
@@ -67,14 +70,32 @@ const DEFAULT_SETTINGS: Settings = {
6770
haptics: true,
6871
autoFlip: true,
6972
showThreats: false,
70-
explodeOnCapture: false
73+
explodeOnCapture: false,
74+
portalCreatorDefault: "N",
75+
portalOpponentDefault: "bot",
76+
portalMaxDefault: 2
7177
};
7278

7379
function normalizePieceSet(value: unknown): PieceSet {
7480
if (value === "classic" || value === "modern" || value === "neon") return value;
7581
return "neon";
7682
}
7783

84+
function normalizePortalCreator(value: unknown): PieceType {
85+
if (value === "Q" || value === "R" || value === "B" || value === "N" || value === "K") return value;
86+
return "N";
87+
}
88+
89+
function normalizePortalOpponent(value: unknown): "two-player" | "bot" {
90+
if (value === "two-player" || value === "bot") return value;
91+
return "bot";
92+
}
93+
94+
function normalizePortalMax(value: unknown): 1 | 2 | 3 {
95+
if (value === 1 || value === 2 || value === 3) return value;
96+
return 2;
97+
}
98+
7899
function emptyStats(): ProfileStats {
79100
return {
80101
wins: 0, losses: 0, draws: 0, rating: 800,
@@ -92,7 +113,10 @@ export function load(): Store {
92113
parsed.settings = {
93114
...DEFAULT_SETTINGS,
94115
...rawSettings,
95-
pieceSet: normalizePieceSet(rawSettings?.pieceSet)
116+
pieceSet: normalizePieceSet(rawSettings?.pieceSet),
117+
portalCreatorDefault: normalizePortalCreator(rawSettings?.portalCreatorDefault),
118+
portalOpponentDefault: normalizePortalOpponent(rawSettings?.portalOpponentDefault),
119+
portalMaxDefault: normalizePortalMax(rawSettings?.portalMaxDefault)
96120
};
97121
parsed.savedGames = parsed.savedGames ?? {};
98122
return parsed;

src/screens/NewGameScreen.tsx

Lines changed: 55 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import { useEffect, useState } from "react";
22
import { Link, useNavigate } from "react-router-dom";
33
import { PieceType } from "../engine/board";
44
import { useGame } from "../GameContext";
@@ -12,10 +12,14 @@ export function NewGameScreen() {
1212
const [whiteName, setWhiteName] = useState<string>(activeProfile?.name ?? "");
1313
const [blackName, setBlackName] = useState<string>("");
1414
// Portal Chess sub-options
15-
const [portalCreator, setPortalCreator] = useState<PieceType>("N");
16-
const [portalOpponentKind, setPortalOpponentKind] = useState<"two-player" | "bot">("bot");
17-
const [portalAdjacencyRule, setPortalAdjacencyRule] = useState<boolean>(false);
18-
const [portalMax, setPortalMax] = useState<1 | 2 | 3>(1);
15+
const [portalCreator, setPortalCreator] = useState<PieceType>(store.settings.portalCreatorDefault);
16+
const [portalOpponentKind, setPortalOpponentKind] = useState<"two-player" | "bot">(store.settings.portalOpponentDefault);
17+
const [portalMax, setPortalMax] = useState<1 | 2 | 3>(store.settings.portalMaxDefault);
18+
const maxLevel = kind === "bot" ? 20 : 10;
19+
20+
useEffect(() => {
21+
if (level > maxLevel) setLevel(maxLevel);
22+
}, [level, maxLevel]);
1923

2024
const ensureProfile = (name: string): string => {
2125
const trimmed = name.trim();
@@ -30,6 +34,9 @@ export function NewGameScreen() {
3034

3135
const start = () => {
3236
updateSetting("timerSeconds", timer);
37+
updateSetting("portalCreatorDefault", portalCreator);
38+
updateSetting("portalOpponentDefault", portalOpponentKind);
39+
updateSetting("portalMaxDefault", portalMax);
3340

3441
// Ensure white profile exists and is active — stats are tied to this name
3542
const w = ensureProfile(whiteName || "Player 1");
@@ -46,12 +53,12 @@ export function NewGameScreen() {
4653
if (portalOpponentKind === "two-player") {
4754
const b = ensureProfile(blackName || "Player 2");
4855
newGame(
49-
{ kind: "portal", opponent: "two-player", creator: portalCreator, adjacencyRule: portalAdjacencyRule, portalMax },
56+
{ kind: "portal", opponent: "two-player", creator: portalCreator, portalMax },
5057
{ w, b }
5158
);
5259
} else {
5360
newGame(
54-
{ kind: "portal", opponent: { kind: "bot", level }, creator: portalCreator, adjacencyRule: portalAdjacencyRule, portalMax },
61+
{ kind: "portal", opponent: { kind: "bot", level }, creator: portalCreator, portalMax },
5562
{ w, b: `Bot Lv ${level}` }
5663
);
5764
}
@@ -138,20 +145,6 @@ export function NewGameScreen() {
138145
</p>
139146
</section>
140147

141-
<section>
142-
<h3>House rules</h3>
143-
<label>
144-
<input type="checkbox"
145-
checked={portalAdjacencyRule}
146-
onChange={(e) => setPortalAdjacencyRule(e.target.checked)} />
147-
{" "}Prevent teleport next to any piece
148-
</label>
149-
<p className="hint">
150-
When ticked, teleport targets cannot be adjacent to any other piece.
151-
When unticked (default), you can teleport anywhere empty &mdash; or stay
152-
on the portal square (the portal remains active).
153-
</p>
154-
</section>
155148
</>
156149
)}
157150

@@ -203,17 +196,48 @@ export function NewGameScreen() {
203196
{showLevel && (
204197
<section>
205198
<h3>Bot difficulty</h3>
206-
<div className="difficulty">
207-
{Array.from({ length: 10 }, (_, i) => i + 1).map((lv) => (
208-
<button
209-
key={lv}
210-
className={lv === level ? "pill active" : "pill"}
211-
onClick={() => setLevel(lv)}
212-
>{lv}</button>
213-
))}
214-
</div>
199+
{kind === "bot" ? (
200+
<div className="bot-level-groups">
201+
<div className="bot-level-group">
202+
<div className="bot-level-label">Learn</div>
203+
<div className="bot-level-grid learn-grid">
204+
{Array.from({ length: 5 }, (_, i) => i + 1).map((lv) => (
205+
<button
206+
key={lv}
207+
className={lv === level ? "pill active" : "pill"}
208+
onClick={() => setLevel(lv)}
209+
>{lv}</button>
210+
))}
211+
</div>
212+
</div>
213+
<div className="bot-level-group">
214+
<div className="bot-level-label">Challenge</div>
215+
<div className="bot-level-grid challenge-grid">
216+
{Array.from({ length: 15 }, (_, i) => i + 6).map((lv) => (
217+
<button
218+
key={lv}
219+
className={lv === level ? "pill active" : "pill"}
220+
onClick={() => setLevel(lv)}
221+
>{lv}</button>
222+
))}
223+
</div>
224+
</div>
225+
</div>
226+
) : (
227+
<div className="difficulty">
228+
{Array.from({ length: maxLevel }, (_, i) => i + 1).map((lv) => (
229+
<button
230+
key={lv}
231+
className={lv === level ? "pill active" : "pill"}
232+
onClick={() => setLevel(lv)}
233+
>{lv}</button>
234+
))}
235+
</div>
236+
)}
215237
<p className="hint">
216-
Level 1 is very easy (good for a new learner). Levels 6+ use a stronger engine when available.
238+
{kind === "bot"
239+
? "Learn levels 1-5 use the local bot and are tuned for practice. Challenge levels 6-20 use Stockfish when online, with local fallback offline."
240+
: "Portal-bot mode uses the in-house engine and supports levels 1-10."}
217241
</p>
218242
</section>
219243
)}

0 commit comments

Comments
 (0)