@@ -2,13 +2,19 @@ import {
22 createContext , ReactNode , useCallback , useContext , useEffect , useMemo , useReducer , useRef , useState
33} from "react" ;
44import {
5- GameState , Move , PieceType , Square , initialState , pieceAt
5+ Color , GameState , Move , PieceType , Square , initialState , pieceAt
66} from "./engine/board" ;
77import {
88 forfeitMove , gameResult , inCheck , legalMovesFrom , makeMove
99} from "./engine/rules" ;
1010import { toSAN } from "./engine/notation" ;
1111import { chooseBotMove } from "./engine/bot" ;
12+ import {
13+ evaluateMoveFeedback ,
14+ type GamePerformanceSummary ,
15+ type MoveFeedback ,
16+ summarizeMoveGrades
17+ } from "./engine/performance" ;
1218import { playSound } from "./engine/sound" ;
1319import {
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 } ;
2531export 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+
2753interface 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.
560737export type { Profile , Settings , Store } ;
0 commit comments