@@ -24,6 +24,10 @@ type Mode =
2424 | { kind : "portal" ; opponent : "two-player" | { kind : "bot" ; level : number } ; creator : PieceType ; adjacencyRule ?: boolean ; portalMax ?: number } ;
2525export interface Players { w : string ; b : string ; }
2626
27+ interface NewGameOptions {
28+ timerSeconds ?: number ;
29+ }
30+
2731/** Build the initial state for Portal Chess (creator-type portals). */
2832function portalInitialState ( creator : PieceType , adjacencyRule = false , portalMax = 1 ) : GameState {
2933 const s = initialState ( ) ;
@@ -50,8 +54,10 @@ interface GameCtx {
5054
5155 select ( sq : Square | null ) : void ;
5256 tryMove ( from : Square , to : Square , promotion ?: "Q" | "R" | "B" | "N" , portalTo ?: Square ) : boolean ;
57+ lastMoveReplayNonce : number ;
58+ replayLastMove ( ) : void ;
5359 undo ( ) : void ;
54- newGame ( mode : Mode , players ?: Partial < Players > ) : void ;
60+ newGame ( mode : Mode , players ?: Partial < Players > , opts ?: NewGameOptions ) : void ;
5561 forfeit ( ) : void ;
5662
5763 // puzzles
@@ -139,6 +145,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
139145 const [ isBotThinking , setIsBotThinking ] = useState ( false ) ;
140146 const [ hint , setHint ] = useState < Move | null > ( null ) ;
141147 const [ paused , setPaused ] = useState ( false ) ;
148+ const [ lastMoveReplayNonce , setLastMoveReplayNonce ] = useState ( 0 ) ;
142149 const stateRef = useRef ( state ) ;
143150 useEffect ( ( ) => { stateRef . current = state ; } , [ state ] ) ;
144151 // Track latest store for callbacks that need the freshest settings.
@@ -245,12 +252,19 @@ export function GameProvider({ children }: { children: ReactNode }) {
245252 try {
246253 // Compute the bot move and ensure enough time has elapsed so the
247254 // human's own animation has time to play before the board re-renders
248- // with the bot's response. Teleport animation is 2.0s total
249- // (demat + gap + remat). Slide animation is ~650ms .
255+ // with the bot's response. Delay scales with animation-speed setting
256+ // so replay/slow modes remain visually readable .
250257 const prevMove = state . history [ state . history . length - 1 ] ;
251- const minThinkMs = prevMove ?. isPortalEntry ? 2100 : 750 ;
258+ const speed = store . settings . animationSpeed ;
259+ const thinkDelays =
260+ speed === "very-slow"
261+ ? { slide : 1650 , teleport : 3800 }
262+ : speed === "slow"
263+ ? { slide : 1200 , teleport : 2900 }
264+ : { slide : 900 , teleport : 2200 } ;
265+ const minThinkMs = prevMove ?. isPortalEntry ? thinkDelays . teleport : thinkDelays . slide ;
252266 const t0 = performance . now ( ) ;
253- const move = await chooseBotMove ( state , lvl ) ;
267+ const move = await chooseBotMove ( state , lvl , { allowExternal : mode . kind === "bot" } ) ;
254268 const elapsed = performance . now ( ) - t0 ;
255269 if ( elapsed < minThinkMs ) {
256270 await new Promise ( ( r ) => setTimeout ( r , minThinkMs - elapsed ) ) ;
@@ -267,7 +281,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
267281 }
268282 } ) ( ) ;
269283 return ( ) => { cancelled = true ; } ;
270- } , [ state , mode , result . kind , paused ] ) ;
284+ } , [ state , mode , result . kind , paused , store . settings . animationSpeed ] ) ;
271285
272286 // Auto-record result when game ends
273287 const recordedRef = useRef < number > ( - 1 ) ;
@@ -347,16 +361,27 @@ export function GameProvider({ children }: { children: ReactNode }) {
347361 return true ;
348362 } , [ state , paused ] ) ;
349363
364+ const replayLastMove = useCallback ( ( ) => {
365+ if ( state . history . length === 0 ) return ;
366+ setLastMoveReplayNonce ( ( n ) => n + 1 ) ;
367+ } , [ state . history . length ] ) ;
368+
350369 const undo = useCallback ( ( ) => {
351370 // Undo twice if playing a bot and it's the human's turn (to remove bot reply + own move).
352371 dispatch ( { type : "undo" } ) ;
353372 if ( botLevelOf ( mode ) !== null ) dispatch ( { type : "undo" } ) ;
354373 setSelected ( null ) ;
355374 } , [ mode ] ) ;
356375
357- const newGame = useCallback ( ( m : Mode , p ?: Partial < Players > ) => {
376+ const newGame = useCallback ( ( m : Mode , p ?: Partial < Players > , opts ?: NewGameOptions ) => {
358377 clearActiveSession ( ) ;
359378 noTimerRef . current = false ;
379+ if ( opts ?. timerSeconds !== undefined && opts . timerSeconds !== storeRef . current . settings . timerSeconds ) {
380+ const updated = cloneStore ( storeRef . current ) ;
381+ updateSettings ( updated , { timerSeconds : opts . timerSeconds } ) ;
382+ setStore ( updated ) ;
383+ storeRef . current = updated ;
384+ }
360385 setMode ( m ) ;
361386 const defaultW = activeProfile ?. name ?? "White" ;
362387 const defaultB =
@@ -370,11 +395,12 @@ export function GameProvider({ children }: { children: ReactNode }) {
370395 dispatch ( { type : "new" , initial : fresh } ) ;
371396 setSelected ( null ) ;
372397 setPaused ( false ) ;
398+ setLastMoveReplayNonce ( 0 ) ;
373399 // Force the clock to use the freshest timer setting — the caller often
374400 // updates settings immediately before calling newGame (e.g. the New Game
375401 // screen), so reading storeRef gives us the value they just picked
376402 // instead of the stale closure-captured one.
377- const t = storeRef . current . settings . timerSeconds ;
403+ const t = opts ?. timerSeconds ?? storeRef . current . settings . timerSeconds ;
378404 setTimeLeft ( t > 0 ? t : Infinity ) ;
379405 } , [ activeProfile ] ) ;
380406
@@ -389,6 +415,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
389415 setSelected ( null ) ;
390416 setHint ( null ) ;
391417 setPaused ( false ) ;
418+ setLastMoveReplayNonce ( 0 ) ;
392419 if ( opts ?. noTimer ) setTimeLeft ( Infinity ) ;
393420 } , [ ] ) ;
394421
@@ -398,10 +425,10 @@ export function GameProvider({ children }: { children: ReactNode }) {
398425
399426 const requestHint = useCallback ( ( ) => {
400427 ( async ( ) => {
401- const best = await chooseBotMove ( state , 5 ) ;
428+ const best = await chooseBotMove ( state , 5 , { allowExternal : mode . kind === "bot" } ) ;
402429 if ( best ) setHint ( best ) ;
403430 } ) ( ) ;
404- } , [ state ] ) ;
431+ } , [ state , mode . kind ] ) ;
405432 const clearHint = useCallback ( ( ) => setHint ( null ) , [ ] ) ;
406433
407434 const setActiveProfile = useCallback ( ( id : string | null ) => {
@@ -458,7 +485,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
458485 const value : GameCtx = {
459486 store, activeProfile, state, mode, players, selected, legalFromSelected, timeLeft, isBotThinking, result,
460487 paused, togglePause,
461- select, tryMove, undo, newGame, forfeit,
488+ select, tryMove, lastMoveReplayNonce , replayLastMove , undo, newGame, forfeit,
462489 loadPosition,
463490 recordPuzzleSolved, recordPuzzleAttempt,
464491 hint, requestHint, clearHint,
0 commit comments