Skip to content

Commit 2db93e7

Browse files
committed
Add move ratings and player performance tracking
1 parent 94057e0 commit 2db93e7

13 files changed

Lines changed: 1098 additions & 38 deletions

src/GameContext.tsx

Lines changed: 186 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ import {
22
createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState
33
} from "react";
44
import {
5-
GameState, Move, PieceType, Square, initialState, pieceAt
5+
Color, GameState, Move, PieceType, Square, initialState, pieceAt
66
} from "./engine/board";
77
import {
88
forfeitMove, gameResult, inCheck, legalMovesFrom, makeMove
99
} from "./engine/rules";
1010
import { toSAN } from "./engine/notation";
1111
import { chooseBotMove } from "./engine/bot";
12+
import {
13+
evaluateMoveFeedback,
14+
type GamePerformanceSummary,
15+
type MoveFeedback,
16+
summarizeMoveGrades
17+
} from "./engine/performance";
1218
import { playSound } from "./engine/sound";
1319
import {
1420
load, Profile, recordResult, saveActiveGame, Settings, Store, updateSettings,
@@ -24,6 +30,26 @@ type Mode =
2430
| { kind: "portal"; opponent: "two-player" | { kind: "bot"; level: number }; creator: PieceType; portalMax?: number };
2531
export interface Players { w: string; b: string; }
2632

33+
interface RatedMoveFeedback extends MoveFeedback {
34+
color: Color;
35+
moveNumber: number;
36+
playerName: string;
37+
}
38+
39+
export interface RatedMoveEntry extends RatedMoveFeedback {
40+
san: string;
41+
}
42+
43+
type CachedRatedMoveEntry = Omit<RatedMoveEntry, "playerName">;
44+
45+
interface PlayerGamePerformance extends GamePerformanceSummary {
46+
color: Color;
47+
playerName: string;
48+
addedStars: number;
49+
totalStars: number | null;
50+
mode: "bot" | "human";
51+
}
52+
2753
interface NewGameOptions {
2854
timerSeconds?: number;
2955
}
@@ -70,6 +96,11 @@ interface GameCtx {
7096
requestHint(): void;
7197
clearHint(): void;
7298

99+
moveFeedback: RatedMoveFeedback | null;
100+
clearMoveFeedback(): void;
101+
ratedMoves: RatedMoveEntry[];
102+
gamePerformance: { w: PlayerGamePerformance | null; b: PlayerGamePerformance | null };
103+
73104
// profile & settings
74105
setActiveProfile(id: string | null): void;
75106
addProfile(name: string): Profile;
@@ -155,17 +186,56 @@ export function GameProvider({ children }: { children: ReactNode }) {
155186
const [hint, setHint] = useState<Move | null>(null);
156187
const [paused, setPaused] = useState(false);
157188
const [lastMoveReplayNonce, setLastMoveReplayNonce] = useState(0);
189+
const [moveFeedback, setMoveFeedback] = useState<RatedMoveFeedback | null>(null);
190+
const [gamePerformance, setGamePerformance] = useState<{ w: PlayerGamePerformance | null; b: PlayerGamePerformance | null }>(blankGamePerformance());
158191
const stateRef = useRef(state);
159192
useEffect(() => { stateRef.current = state; }, [state]);
160193
// Track latest store for callbacks that need the freshest settings.
161194
const storeRef = useRef(store);
162195
useEffect(() => { storeRef.current = store; }, [store]);
196+
const moveFeedbackTimeoutRef = useRef<number | null>(null);
197+
const moveGradesRef = useRef<{ w: number[]; b: number[] }>({ w: [], b: [] });
198+
const queuedMoveSoundRef = useRef<"blunder" | "grandmaster" | null>(null);
199+
const ratedMoveCacheRef = useRef<Array<CachedRatedMoveEntry | null>>([]);
200+
201+
useEffect(() => () => {
202+
if (moveFeedbackTimeoutRef.current !== null) window.clearTimeout(moveFeedbackTimeoutRef.current);
203+
}, []);
163204

164205
const activeProfile = useMemo(
165206
() => store.profiles.find((p) => p.id === store.settings.activeProfileId) ?? null,
166207
[store]
167208
);
168209

210+
const ratedMoves = useMemo<RatedMoveEntry[]>(() => {
211+
const cache = ratedMoveCacheRef.current.slice(0, state.history.length);
212+
for (let index = 0; index < state.history.length; index++) {
213+
if (cache[index]) continue;
214+
const before = stack[index];
215+
const after = stack[index + 1];
216+
const move = after?.history[index];
217+
if (!move || move.from.file < 0) {
218+
cache[index] = null;
219+
continue;
220+
}
221+
const feedback = evaluateMoveFeedback(before, move);
222+
cache[index] = {
223+
...feedback,
224+
color: move.color,
225+
moveNumber: index + 1,
226+
san: move.san ?? ""
227+
};
228+
}
229+
ratedMoveCacheRef.current = cache;
230+
return cache.flatMap((entry) => {
231+
if (!entry) return [];
232+
return [{
233+
...entry,
234+
playerName: entry.color === "w" ? players.w : players.b
235+
}];
236+
});
237+
}, [stack, state.history.length, players]);
238+
169239
const result = useMemo(() => gameResult(state), [state]);
170240

171241
// Clear hint whenever the position changes.
@@ -182,9 +252,14 @@ export function GameProvider({ children }: { children: ReactNode }) {
182252
// win/loss from the active profile's perspective (white) when vs bot
183253
const playerIsWhite = botLevelOf(mode) !== null;
184254
const winnerIsPlayer = playerIsWhite && result.winner === "w";
255+
queuedMoveSoundRef.current = null;
185256
playSound(winnerIsPlayer ? "win" : (botLevelOf(mode) !== null ? "loss" : "win"));
186257
} else if (result.kind !== "ongoing") {
258+
queuedMoveSoundRef.current = null;
187259
playSound("draw");
260+
} else if (queuedMoveSoundRef.current && lastMove) {
261+
playSound(queuedMoveSoundRef.current);
262+
queuedMoveSoundRef.current = null;
188263
} else if (isCheck) {
189264
playSound("check");
190265
} else if (lastMove?.isPortalEntry) {
@@ -304,6 +379,8 @@ export function GameProvider({ children }: { children: ReactNode }) {
304379
}
305380
if (cancelled) return;
306381
if (move) {
382+
const feedback = evaluateMoveFeedback(state, move);
383+
queuedMoveSoundRef.current = feedback.grade === 0 ? "blunder" : null;
307384
const san = toSAN(state, move);
308385
dispatch({ type: "make", move, san });
309386
}
@@ -325,23 +402,52 @@ export function GameProvider({ children }: { children: ReactNode }) {
325402
const winner = result.kind === "checkmate" ? result.winner : null;
326403
const updated = cloneStore(store);
327404
const lvl = botLevelOf(mode);
405+
const summaries = {
406+
w: isHumanControlledColor(mode, "w") ? summarizeMoveGrades(moveGradesRef.current.w) : null,
407+
b: isHumanControlledColor(mode, "b") ? summarizeMoveGrades(moveGradesRef.current.b) : null
408+
};
409+
const nextPerformance = blankGamePerformance();
328410
if (lvl !== null) {
329-
if (!activeProfile) return;
330411
const outcome: "win" | "loss" | "draw" =
331412
winner === null ? "draw" : winner === "w" ? "win" : "loss";
332-
recordResult(updated, activeProfile.id, { kind: "bot", level: lvl }, outcome);
333-
saveActiveGame(updated, activeProfile.id, null);
413+
if (activeProfile && summaries.w) {
414+
recordResult(updated, activeProfile.id, { kind: "bot", level: lvl }, outcome, {
415+
stars: summaries.w.stars,
416+
score: summaries.w.score,
417+
moveGrades: moveGradesRef.current.w
418+
});
419+
saveActiveGame(updated, activeProfile.id, null);
420+
const refreshed = updated.profiles.find((p) => p.id === activeProfile.id) ?? null;
421+
nextPerformance.w = toPlayerGamePerformance("w", players.w, summaries.w, refreshed?.stats.totalStars ?? null, "bot");
422+
} else if (summaries.w) {
423+
nextPerformance.w = toPlayerGamePerformance("w", players.w, summaries.w, null, "bot");
424+
}
334425
} else {
335426
// two-player: record vs each named profile if found
336427
const wProf = updated.profiles.find((p) => p.name === players.w);
337428
const bProf = updated.profiles.find((p) => p.name === players.b);
338429
const wOutcome: "win" | "loss" | "draw" = winner === null ? "draw" : winner === "w" ? "win" : "loss";
339430
const bOutcome: "win" | "loss" | "draw" = winner === null ? "draw" : winner === "b" ? "win" : "loss";
340-
if (wProf) recordResult(updated, wProf.id, { kind: "human" }, wOutcome);
341-
if (bProf) recordResult(updated, bProf.id, { kind: "human" }, bOutcome);
431+
if (wProf && summaries.w) {
432+
recordResult(updated, wProf.id, { kind: "human" }, wOutcome, {
433+
stars: summaries.w.stars,
434+
score: summaries.w.score,
435+
moveGrades: moveGradesRef.current.w
436+
});
437+
}
438+
if (bProf && summaries.b) {
439+
recordResult(updated, bProf.id, { kind: "human" }, bOutcome, {
440+
stars: summaries.b.stars,
441+
score: summaries.b.score,
442+
moveGrades: moveGradesRef.current.b
443+
});
444+
}
445+
if (summaries.w) nextPerformance.w = toPlayerGamePerformance("w", players.w, summaries.w, wProf?.stats.totalStars ?? null, "human");
446+
if (summaries.b) nextPerformance.b = toPlayerGamePerformance("b", players.b, summaries.b, bProf?.stats.totalStars ?? null, "human");
342447
if (activeProfile) saveActiveGame(updated, activeProfile.id, null);
343448
}
344449
setStore(updated);
450+
setGamePerformance(nextPerformance);
345451
}, [result, mode, activeProfile, store, stack.length, players]);
346452

347453
// Persist active game
@@ -375,6 +481,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
375481

376482
const tryMove = useCallback((from: Square, to: Square, promotion?: "Q" | "R" | "B" | "N", portalTo?: Square): boolean => {
377483
if (paused) return false;
484+
const mover = state.turn;
378485
const candidates = legalMovesFrom(state, from).filter(
379486
(m) => m.to.file === to.file && m.to.rank === to.rank
380487
);
@@ -388,23 +495,47 @@ export function GameProvider({ children }: { children: ReactNode }) {
388495
} else if (candidates.some((m) => m.promotion)) {
389496
move = candidates.find((m) => m.promotion === (promotion ?? "Q")) ?? candidates[0];
390497
}
498+
const feedback = isHumanControlledColor(mode, mover) ? evaluateMoveFeedback(state, move) : null;
391499
const san = toSAN(state, move);
392500
dispatch({ type: "make", move, san });
393501
setSelected(null);
502+
if (feedback) {
503+
queuedMoveSoundRef.current = feedback.sound;
504+
moveGradesRef.current[mover] = [...moveGradesRef.current[mover], feedback.grade];
505+
setGamePerformance(blankGamePerformance());
506+
setMoveFeedback({
507+
...feedback,
508+
color: mover,
509+
moveNumber: state.history.length + 1,
510+
playerName: mover === "w" ? players.w : players.b
511+
});
512+
if (moveFeedbackTimeoutRef.current !== null) window.clearTimeout(moveFeedbackTimeoutRef.current);
513+
moveFeedbackTimeoutRef.current = window.setTimeout(() => setMoveFeedback(null), 2100);
514+
}
394515
return true;
395-
}, [state, paused]);
516+
}, [state, paused, mode, players]);
396517

397518
const replayLastMove = useCallback(() => {
398519
if (state.history.length === 0) return;
399520
setLastMoveReplayNonce((n) => n + 1);
400521
}, [state.history.length]);
401522

402523
const undo = useCallback(() => {
524+
const undoCount = botLevelOf(mode) !== null ? 2 : 1;
525+
const undoneMoves = state.history.slice(-undoCount);
526+
for (const move of undoneMoves) {
527+
if (move && move.from.file >= 0 && isHumanControlledColor(mode, move.color)) {
528+
moveGradesRef.current[move.color] = moveGradesRef.current[move.color].slice(0, -1);
529+
}
530+
}
403531
// Undo twice if playing a bot and it's the human's turn (to remove bot reply + own move).
404532
dispatch({ type: "undo" });
405533
if (botLevelOf(mode) !== null) dispatch({ type: "undo" });
406534
setSelected(null);
407-
}, [mode]);
535+
queuedMoveSoundRef.current = null;
536+
setMoveFeedback(null);
537+
setGamePerformance(blankGamePerformance());
538+
}, [mode, state.history]);
408539

409540
const newGame = useCallback((m: Mode, p?: Partial<Players>, opts?: NewGameOptions) => {
410541
if (
@@ -437,6 +568,11 @@ export function GameProvider({ children }: { children: ReactNode }) {
437568
setSelected(null);
438569
setPaused(false);
439570
setLastMoveReplayNonce(0);
571+
moveGradesRef.current = { w: [], b: [] };
572+
queuedMoveSoundRef.current = null;
573+
if (moveFeedbackTimeoutRef.current !== null) window.clearTimeout(moveFeedbackTimeoutRef.current);
574+
setMoveFeedback(null);
575+
setGamePerformance(blankGamePerformance());
440576
// Force the clock to use the freshest timer setting — the caller often
441577
// updates settings immediately before calling newGame (e.g. the New Game
442578
// screen), so reading storeRef gives us the value they just picked
@@ -457,6 +593,11 @@ export function GameProvider({ children }: { children: ReactNode }) {
457593
setHint(null);
458594
setPaused(false);
459595
setLastMoveReplayNonce(0);
596+
moveGradesRef.current = { w: [], b: [] };
597+
queuedMoveSoundRef.current = null;
598+
if (moveFeedbackTimeoutRef.current !== null) window.clearTimeout(moveFeedbackTimeoutRef.current);
599+
setMoveFeedback(null);
600+
setGamePerformance(blankGamePerformance());
460601
if (opts?.noTimer) setTimeLeft(Infinity);
461602
}, []);
462603

@@ -471,6 +612,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
471612
})();
472613
}, [state, mode.kind]);
473614
const clearHint = useCallback(() => setHint(null), []);
615+
const clearMoveFeedback = useCallback(() => setMoveFeedback(null), []);
474616

475617
const setActiveProfile = useCallback((id: string | null) => {
476618
const updated = cloneStore(store);
@@ -530,6 +672,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
530672
loadPosition,
531673
recordPuzzleSolved, recordPuzzleAttempt,
532674
hint, requestHint, clearHint,
675+
moveFeedback, clearMoveFeedback, ratedMoves, gamePerformance,
533676
setActiveProfile, addProfile, removeProfile, renamePlayer, updateSetting
534677
};
535678

@@ -548,13 +691,47 @@ function cloneStore(s: Store): Store {
548691
...p.stats,
549692
byBotLevel: { ...p.stats.byBotLevel },
550693
badges: p.stats.badges.slice(),
551-
puzzleProgress: { ...(p.stats.puzzleProgress ?? {}) }
694+
puzzleProgress: { ...(p.stats.puzzleProgress ?? {}) },
695+
performanceHistory: (p.stats.performanceHistory ?? []).map((record) => ({
696+
...record,
697+
moveGrades: record.moveGrades?.slice()
698+
}))
552699
}
553700
})),
554701
settings: { ...s.settings },
555702
savedGames: { ...s.savedGames }
556703
};
557704
}
558705

706+
function isHumanControlledColor(mode: Mode, color: Color): boolean {
707+
if (mode.kind === "bot") return color === "w";
708+
if (mode.kind === "portal") {
709+
if (typeof mode.opponent === "string") return true;
710+
return color === "w";
711+
}
712+
return true;
713+
}
714+
715+
function blankGamePerformance(): { w: PlayerGamePerformance | null; b: PlayerGamePerformance | null } {
716+
return { w: null, b: null };
717+
}
718+
719+
function toPlayerGamePerformance(
720+
color: Color,
721+
playerName: string,
722+
summary: GamePerformanceSummary,
723+
totalStars: number | null,
724+
mode: "bot" | "human"
725+
): PlayerGamePerformance {
726+
return {
727+
...summary,
728+
color,
729+
playerName,
730+
addedStars: summary.stars,
731+
totalStars,
732+
mode
733+
};
734+
}
735+
559736
// Unused re-exports to keep bundler happy for tree-shaking consumers.
560737
export type { Profile, Settings, Store };

src/components/Board.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ export function Board({ flipped = false }: Props) {
4141
store,
4242
mode,
4343
hint,
44-
lastMoveReplayNonce
44+
lastMoveReplayNonce,
45+
moveFeedback,
46+
clearMoveFeedback
4547
} = useGame();
4648
const theme = store.settings.theme;
4749
const pieceSet = store.settings.pieceSet;
@@ -251,13 +253,29 @@ export function Board({ flipped = false }: Props) {
251253
<span className="boom-bit b6"></span>
252254
</span>
253255
)}
256+
{isLastTo && moveFeedback && (
257+
<span className={`move-rating-badge grade-${moveFeedback.grade}`} aria-hidden="true">
258+
{moveFeedback.emoji}
259+
</span>
260+
)}
254261
</button>
255262
);
256263
})}
257264
</div>
258265
))}
259266
</div>
260267

268+
{moveFeedback && store.settings.showMoveRatingPopup && (
269+
<div className={`move-rating-toast grade-${moveFeedback.grade}`} role="status" aria-live="polite">
270+
<button className="move-rating-dismiss" onClick={clearMoveFeedback} aria-label="Dismiss move rating"></button>
271+
<div className="move-rating-emoji" aria-hidden="true">{moveFeedback.emoji}</div>
272+
<div className="move-rating-copy">
273+
<strong>{moveFeedback.playerName}: {moveFeedback.label}</strong>
274+
<span>{moveFeedback.title} · {moveFeedback.score}/100</span>
275+
</div>
276+
</div>
277+
)}
278+
261279
{pending && (
262280
<div className="modal-overlay" onClick={() => setPending(null)}>
263281
<div className={`modal promo-picker piece-set-${pieceSet}`} onClick={(e) => e.stopPropagation()}>

0 commit comments

Comments
 (0)