feat: add pause/resume game state architecture
Implements the core state management and validation for pausing and resuming arcade games. When players navigate to setup during an active game, the game state is saved and can be resumed if configuration hasn't changed. Core changes: - Add GameConfiguration, pausedGamePhase, pausedGameState to track paused games - Add hasConfigChanged and canResumeGame computed properties - Add RESUME_GAME move type for restoring paused games Validator logic: - GO_TO_SETUP saves game state snapshot when called from playing/results - SET_CONFIG clears paused game state if config changes - RESUME_GAME validates and restores paused game state - START_GAME tracks originalConfig for change detection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,20 @@ export interface GameStatistics {
|
||||
averageTimePerMove: number
|
||||
}
|
||||
|
||||
export interface PlayerMetadata {
|
||||
id: string // Player ID
|
||||
name: string
|
||||
emoji: string
|
||||
userId: string // Which user owns this player
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface GameConfiguration {
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
export interface MemoryPairsState {
|
||||
// Core game data
|
||||
cards: GameCard[]
|
||||
@@ -51,6 +65,22 @@ export interface MemoryPairsState {
|
||||
difficulty: Difficulty
|
||||
turnTimer: number // Seconds for two-player mode
|
||||
|
||||
// Paused game state - for Resume functionality
|
||||
originalConfig?: GameConfiguration // Config when game started - used to detect changes
|
||||
pausedGamePhase?: 'playing' | 'results' // Set when GO_TO_SETUP called from active game
|
||||
pausedGameState?: {
|
||||
// Snapshot of game state when paused
|
||||
gameCards: GameCard[]
|
||||
currentPlayer: Player
|
||||
matchedPairs: number
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[]
|
||||
playerMetadata: { [playerId: string]: PlayerMetadata }
|
||||
consecutiveMatches: { [playerId: string]: number }
|
||||
gameStartTime: number | null
|
||||
}
|
||||
|
||||
// Game progression
|
||||
gamePhase: GamePhase
|
||||
currentPlayer: Player
|
||||
@@ -59,6 +89,7 @@ export interface MemoryPairsState {
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[] // Track active player IDs
|
||||
playerMetadata: { [playerId: string]: PlayerMetadata } // Player metadata snapshot for cross-user visibility
|
||||
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
|
||||
|
||||
// Timing
|
||||
@@ -101,13 +132,18 @@ export interface MemoryPairsContextValue {
|
||||
currentGameStatistics: GameStatistics
|
||||
gameMode: GameMode // Derived from global context
|
||||
activePlayers: Player[] // Active player IDs from arena
|
||||
hasConfigChanged: boolean // True if current config differs from originalConfig
|
||||
canResumeGame: boolean // True if there's a paused game and config hasn't changed
|
||||
|
||||
// Actions
|
||||
startGame: () => void
|
||||
resumeGame: () => void
|
||||
flipCard: (cardId: string) => void
|
||||
resetGame: () => void
|
||||
setGameType: (type: GameType) => void
|
||||
setDifficulty: (difficulty: Difficulty) => void
|
||||
setTurnTimer: (timer: number) => void
|
||||
goToSetup: () => void
|
||||
exitSession: () => void // Exit arcade session (no-op for non-arcade mode)
|
||||
}
|
||||
|
||||
@@ -133,14 +169,6 @@ export interface GameGridProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// Configuration interfaces
|
||||
export interface GameConfiguration {
|
||||
gameMode: GameMode
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
export interface MatchValidationResult {
|
||||
isValid: boolean
|
||||
reason?: string
|
||||
|
||||
@@ -25,11 +25,25 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
return this.validateFlipCard(state, move.data.cardId, move.playerId, context)
|
||||
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.cards)
|
||||
return this.validateStartGame(
|
||||
state,
|
||||
move.data.activePlayers,
|
||||
move.data.cards,
|
||||
move.data.playerMetadata
|
||||
)
|
||||
|
||||
case 'CLEAR_MISMATCH':
|
||||
return this.validateClearMismatch(state)
|
||||
|
||||
case 'GO_TO_SETUP':
|
||||
return this.validateGoToSetup(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value)
|
||||
|
||||
case 'RESUME_GAME':
|
||||
return this.validateResumeGame(state)
|
||||
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
@@ -183,7 +197,8 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
private validateStartGame(
|
||||
state: MemoryPairsState,
|
||||
activePlayers: Player[],
|
||||
cards?: GameCard[]
|
||||
cards?: GameCard[],
|
||||
playerMetadata?: { [playerId: string]: any }
|
||||
): ValidationResult {
|
||||
// Allow starting a new game from any phase (for "New Game" button)
|
||||
|
||||
@@ -203,6 +218,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
gameCards,
|
||||
cards: gameCards,
|
||||
activePlayers,
|
||||
playerMetadata: playerMetadata || {}, // Store player metadata for cross-user visibility
|
||||
gamePhase: 'playing',
|
||||
gameStartTime: Date.now(),
|
||||
currentPlayer: activePlayers[0],
|
||||
@@ -211,6 +227,15 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
moves: 0,
|
||||
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
// PAUSE/RESUME: Save original config so we can detect changes
|
||||
originalConfig: {
|
||||
gameType: state.gameType,
|
||||
difficulty: state.difficulty,
|
||||
turnTimer: state.turnTimer,
|
||||
},
|
||||
// Clear any paused game state (starting fresh)
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -220,6 +245,16 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
}
|
||||
|
||||
private validateClearMismatch(state: MemoryPairsState): ValidationResult {
|
||||
// Only clear if there's actually a mismatch showing
|
||||
// This prevents race conditions where CLEAR_MISMATCH arrives after cards have already been cleared
|
||||
if (!state.showMismatchFeedback || state.flippedCards.length === 0) {
|
||||
// Nothing to clear - return current state unchanged
|
||||
return {
|
||||
valid: true,
|
||||
newState: state,
|
||||
}
|
||||
}
|
||||
|
||||
// Clear mismatched cards and feedback
|
||||
return {
|
||||
valid: true,
|
||||
@@ -232,6 +267,218 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STANDARD ARCADE PATTERN: GO_TO_SETUP
|
||||
*
|
||||
* Transitions the game back to setup phase, allowing players to reconfigure
|
||||
* the game. This is synchronized across all room members.
|
||||
*
|
||||
* Can be called from any phase (setup, playing, results).
|
||||
*
|
||||
* PAUSE/RESUME: If called from 'playing' or 'results', saves game state
|
||||
* to allow resuming later (if config unchanged).
|
||||
*
|
||||
* Pattern for all arcade games:
|
||||
* - Validates the move is allowed
|
||||
* - Sets gamePhase to 'setup'
|
||||
* - Preserves current configuration (gameType, difficulty, etc.)
|
||||
* - Saves game state for resume if coming from active game
|
||||
* - Resets game progression state (scores, cards, etc.)
|
||||
*/
|
||||
private validateGoToSetup(state: MemoryPairsState): ValidationResult {
|
||||
// Determine if we're pausing an active game (for Resume functionality)
|
||||
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
|
||||
// Pause/Resume: Save game state if pausing from active game
|
||||
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
|
||||
pausedGameState: isPausingGame
|
||||
? {
|
||||
gameCards: state.gameCards,
|
||||
currentPlayer: state.currentPlayer,
|
||||
matchedPairs: state.matchedPairs,
|
||||
moves: state.moves,
|
||||
scores: state.scores,
|
||||
activePlayers: state.activePlayers,
|
||||
playerMetadata: state.playerMetadata,
|
||||
consecutiveMatches: state.consecutiveMatches,
|
||||
gameStartTime: state.gameStartTime,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
// Keep originalConfig if it exists (was set when game started)
|
||||
// This allows detecting if config changed while paused
|
||||
|
||||
// Reset visible game progression
|
||||
gameCards: [],
|
||||
cards: [],
|
||||
flippedCards: [],
|
||||
currentPlayer: '',
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
// Preserve configuration - players can modify in setup
|
||||
// gameType, difficulty, turnTimer stay as-is
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STANDARD ARCADE PATTERN: SET_CONFIG
|
||||
*
|
||||
* Updates a configuration field during setup phase. This is synchronized
|
||||
* across all room members in real-time, allowing collaborative setup.
|
||||
*
|
||||
* Pattern for all arcade games:
|
||||
* - Only allowed during setup phase
|
||||
* - Validates field name and value
|
||||
* - Updates the configuration field
|
||||
* - Other room members see the change immediately (optimistic + server validation)
|
||||
*
|
||||
* @param state Current game state
|
||||
* @param field Configuration field name
|
||||
* @param value New value for the field
|
||||
*/
|
||||
private validateSetConfig(
|
||||
state: MemoryPairsState,
|
||||
field: 'gameType' | 'difficulty' | 'turnTimer',
|
||||
value: any
|
||||
): ValidationResult {
|
||||
// Can only change config during setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot change configuration outside of setup phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field-specific values
|
||||
switch (field) {
|
||||
case 'gameType':
|
||||
if (value !== 'abacus-numeral' && value !== 'complement-pairs') {
|
||||
return { valid: false, error: `Invalid gameType: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
case 'difficulty':
|
||||
if (![6, 8, 12, 15].includes(value)) {
|
||||
return { valid: false, error: `Invalid difficulty: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
case 'turnTimer':
|
||||
if (typeof value !== 'number' || value < 5 || value > 300) {
|
||||
return { valid: false, error: `Invalid turnTimer: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
return { valid: false, error: `Unknown config field: ${field}` }
|
||||
}
|
||||
|
||||
// PAUSE/RESUME: If there's a paused game and config is changing,
|
||||
// clear the paused game state (can't resume anymore)
|
||||
const clearPausedGame = !!state.pausedGamePhase
|
||||
|
||||
// Apply the configuration change
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
[field]: value,
|
||||
// Update totalPairs if difficulty changes
|
||||
...(field === 'difficulty' ? { totalPairs: value } : {}),
|
||||
// Clear paused game if config changed
|
||||
...(clearPausedGame
|
||||
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STANDARD ARCADE PATTERN: RESUME_GAME
|
||||
*
|
||||
* Resumes a paused game if configuration hasn't changed.
|
||||
* Restores the saved game state from when GO_TO_SETUP was called.
|
||||
*
|
||||
* Pattern for all arcade games:
|
||||
* - Validates there's a paused game
|
||||
* - Validates config hasn't changed since pause
|
||||
* - Restores game state and phase
|
||||
* - Clears paused game state
|
||||
*/
|
||||
private validateResumeGame(state: MemoryPairsState): ValidationResult {
|
||||
// Must be in setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only resume from setup phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Must have a paused game
|
||||
if (!state.pausedGamePhase || !state.pausedGameState) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'No paused game to resume',
|
||||
}
|
||||
}
|
||||
|
||||
// Config must match original (no changes while paused)
|
||||
if (state.originalConfig) {
|
||||
const configChanged =
|
||||
state.gameType !== state.originalConfig.gameType ||
|
||||
state.difficulty !== state.originalConfig.difficulty ||
|
||||
state.turnTimer !== state.originalConfig.turnTimer
|
||||
|
||||
if (configChanged) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot resume - configuration has changed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the paused game
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: state.pausedGamePhase,
|
||||
gameCards: state.pausedGameState.gameCards,
|
||||
cards: state.pausedGameState.gameCards,
|
||||
currentPlayer: state.pausedGameState.currentPlayer,
|
||||
matchedPairs: state.pausedGameState.matchedPairs,
|
||||
moves: state.pausedGameState.moves,
|
||||
scores: state.pausedGameState.scores,
|
||||
activePlayers: state.pausedGameState.activePlayers,
|
||||
playerMetadata: state.pausedGameState.playerMetadata,
|
||||
consecutiveMatches: state.pausedGameState.consecutiveMatches,
|
||||
gameStartTime: state.pausedGameState.gameStartTime,
|
||||
// Clear paused state
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
// Keep originalConfig for potential future pauses
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
isGameComplete(state: MemoryPairsState): boolean {
|
||||
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
|
||||
}
|
||||
@@ -255,6 +502,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {}, // Initialize empty player metadata
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
@@ -264,6 +512,10 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
// PAUSE/RESUME: Initialize paused game fields
|
||||
originalConfig: undefined,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface MatchingStartGameMove extends GameMove {
|
||||
data: {
|
||||
activePlayers: string[] // Player IDs (UUIDs)
|
||||
cards?: any[] // GameCard type from context
|
||||
playerMetadata?: { [playerId: string]: any } // Player metadata for cross-user visibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +42,32 @@ export interface MatchingClearMismatchMove extends GameMove {
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
// Standard setup moves - pattern for all arcade games
|
||||
export interface MatchingGoToSetupMove extends GameMove {
|
||||
type: 'GO_TO_SETUP'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MatchingSetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'gameType' | 'difficulty' | 'turnTimer'
|
||||
value: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface MatchingResumeGameMove extends GameMove {
|
||||
type: 'RESUME_GAME'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export type MatchingGameMove =
|
||||
| MatchingFlipCardMove
|
||||
| MatchingStartGameMove
|
||||
| MatchingClearMismatchMove
|
||||
| MatchingGoToSetupMove
|
||||
| MatchingSetConfigMove
|
||||
| MatchingResumeGameMove
|
||||
|
||||
// Generic game state union
|
||||
export type GameState = MemoryPairsState // Add other game states as union later
|
||||
|
||||
Reference in New Issue
Block a user