fix: add CLEAR_MISMATCH move to allow mismatch feedback to auto-dismiss

- Add MatchingClearMismatchMove type to arcade validation types
- Implement CLEAR_MISMATCH validation in MatchingGameValidator
- Keep mismatched cards visible briefly (1.5s) so player can see them
- Auto-dismiss mismatch feedback toast after timeout
- Essential for memory gameplay where seeing wrong cards builds memory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-06 11:04:03 -05:00
parent 7829d8a0fb
commit 158f52773d
3 changed files with 547 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
'use client'
import { createContext, useContext, useCallback, useMemo, useEffect, type ReactNode } from 'react'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useViewerId } from '@/hooks/useViewerId'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { generateGameCards } from '../utils/cardGeneration'
import type {
MemoryPairsState,
MemoryPairsContextValue,
GameStatistics,
} from './types'
import type { GameMove } from '@/lib/arcade/validation'
// Initial state
const initialState: MemoryPairsState = {
cards: [],
gameCards: [],
flippedCards: [],
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
gamePhase: 'setup',
currentPlayer: 1,
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: [],
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null
}
/**
* Optimistic move application (client-side prediction)
* The server will validate and send back the authoritative state
*/
function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): MemoryPairsState {
switch (move.type) {
case 'START_GAME':
// Generate cards and initialize game
return {
...state,
gamePhase: 'playing',
gameCards: move.data.cards,
cards: move.data.cards,
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: move.data.activePlayers.reduce((acc: any, p: number) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: move.data.activePlayers.reduce((acc: any, p: number) => ({ ...acc, [p]: 0 }), {}),
activePlayers: move.data.activePlayers,
currentPlayer: move.data.activePlayers[0] || 1,
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null
}
case 'FLIP_CARD': {
// Optimistically flip the card
const card = state.gameCards.find(c => c.id === move.data.cardId)
if (!card) return state
const newFlippedCards = [...state.flippedCards, card]
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime: state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
showMismatchFeedback: false
}
}
case 'CLEAR_MISMATCH': {
// Clear mismatched cards and feedback
return {
...state,
flippedCards: [],
showMismatchFeedback: false,
isProcessingMove: false
}
}
default:
return state
}
}
// Create context
const ArcadeMemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
// Provider component
export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
// Get active player IDs as numbers
const activePlayers = Array.from(activePlayerIds).map((id, index) => index + 1)
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
// Arcade session integration
const { state, sendMove, connected, exitSession } = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
initialState,
applyMove: applyMoveOptimistically,
})
// Handle mismatch feedback timeout
useEffect(() => {
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
// After 1.5 seconds, clear the flipped cards and feedback
const timeout = setTimeout(() => {
sendMove({
type: 'CLEAR_MISMATCH',
data: {}
})
}, 1500)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = useCallback((cardId: string): boolean => {
if (!isGameActive || state.isProcessingMove) return false
const card = state.gameCards.find(c => c.id === cardId)
if (!card || card.matched) return false
// Can't flip if already flipped
if (state.flippedCards.some(c => c.id === cardId)) return false
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) return false
return true
}, [isGameActive, state.isProcessingMove, state.gameCards, state.flippedCards])
const currentGameStatistics: GameStatistics = useMemo(() => ({
totalMoves: state.moves,
matchedPairs: state.matchedPairs,
totalPairs: state.totalPairs,
gameTime: state.gameStartTime ?
(state.gameEndTime || Date.now()) - state.gameStartTime : 0,
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
averageTimePerMove: state.moves > 0 && state.gameStartTime ?
((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves : 0
}), [state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime])
// Action creators - send moves to arcade session
const startGame = useCallback(() => {
const cards = generateGameCards(state.gameType, state.difficulty)
sendMove({
type: 'START_GAME',
data: {
cards,
activePlayers
}
})
}, [state.gameType, state.difficulty, activePlayers, sendMove])
const flipCard = useCallback((cardId: string) => {
if (!canFlipCard(cardId)) return
sendMove({
type: 'FLIP_CARD',
data: { cardId }
})
}, [canFlipCard, sendMove])
const resetGame = useCallback(() => {
// Delete current session and start a new game
const cards = generateGameCards(state.gameType, state.difficulty)
sendMove({
type: 'START_GAME',
data: {
cards,
activePlayers
}
})
}, [state.gameType, state.difficulty, activePlayers, sendMove])
const setGameType = useCallback((gameType: typeof state.gameType) => {
// TODO: Implement via arcade session if needed
console.warn('setGameType not yet implemented for arcade mode')
}, [])
const setDifficulty = useCallback((difficulty: typeof state.difficulty) => {
// TODO: Implement via arcade session if needed
console.warn('setDifficulty not yet implemented for arcade mode')
}, [])
const contextValue: MemoryPairsContextValue = {
state: { ...state, gameMode },
dispatch: () => {
// No-op - replaced with sendMove
console.warn('dispatch() is deprecated in arcade mode, use action creators instead')
},
isGameActive,
canFlipCard,
currentGameStatistics,
startGame,
flipCard,
resetGame,
setGameType,
setDifficulty,
exitSession,
gameMode,
activePlayers
}
return (
<ArcadeMemoryPairsContext.Provider value={contextValue}>
{children}
</ArcadeMemoryPairsContext.Provider>
)
}
// Hook to use the context
export function useArcadeMemoryPairs(): MemoryPairsContextValue {
const context = useContext(ArcadeMemoryPairsContext)
if (!context) {
throw new Error('useArcadeMemoryPairs must be used within an ArcadeMemoryPairsProvider')
}
return context
}

View File

@@ -0,0 +1,239 @@
/**
* Server-side validator for matching game
* Validates all game moves and state transitions
*/
import type {
GameValidator,
ValidationResult,
MatchingGameMove,
} from './types'
import type { MemoryPairsState, GameCard, Difficulty, GameType } from '@/app/games/matching/context/types'
import { validateMatch, canFlipCard } from '@/app/games/matching/utils/matchValidation'
import { generateGameCards } from '@/app/games/matching/utils/cardGeneration'
export class MatchingGameValidator implements GameValidator<MemoryPairsState, MatchingGameMove> {
validateMove(state: MemoryPairsState, move: MatchingGameMove): ValidationResult {
switch (move.type) {
case 'FLIP_CARD':
return this.validateFlipCard(state, move.data.cardId, move.playerId)
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers, move.data.cards)
case 'CLEAR_MISMATCH':
return this.validateClearMismatch(state)
default:
return {
valid: false,
error: `Unknown move type: ${(move as any).type}`,
}
}
}
private validateFlipCard(
state: MemoryPairsState,
cardId: string,
playerId: string
): ValidationResult {
// Game must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Cannot flip cards outside of playing phase',
}
}
// Check if it's the player's turn (in multiplayer)
if (state.activePlayers.length > 1 && state.currentPlayer.toString() !== playerId) {
return {
valid: false,
error: 'Not your turn',
}
}
// Find the card
const card = state.gameCards.find(c => c.id === cardId)
if (!card) {
return {
valid: false,
error: 'Card not found',
}
}
// Validate using existing game logic
if (!canFlipCard(card, state.flippedCards, state.isProcessingMove)) {
return {
valid: false,
error: 'Cannot flip this card',
}
}
// Calculate new state
const newFlippedCards = [...state.flippedCards, card]
let newState = {
...state,
flippedCards: newFlippedCards,
isProcessingMove: newFlippedCards.length === 2,
// Clear mismatch feedback when player flips a new card
showMismatchFeedback: false,
}
// If two cards are flipped, check for match
if (newFlippedCards.length === 2) {
const [card1, card2] = newFlippedCards
const matchResult = validateMatch(card1, card2)
if (matchResult.isValid) {
// Match found - update cards
newState = {
...newState,
gameCards: newState.gameCards.map(c =>
c.id === card1.id || c.id === card2.id
? { ...c, matched: true, matchedBy: state.currentPlayer }
: c
),
matchedPairs: state.matchedPairs + 1,
scores: {
...state.scores,
[state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1,
},
consecutiveMatches: {
...state.consecutiveMatches,
[state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1,
},
moves: state.moves + 1,
flippedCards: [],
isProcessingMove: false,
}
// Check if game is complete
if (newState.matchedPairs === newState.totalPairs) {
newState = {
...newState,
gamePhase: 'results',
gameEndTime: Date.now(),
}
}
} else {
// Match failed - keep cards flipped briefly so player can see them
// Client will handle clearing them after a delay
const shouldSwitchPlayer = state.activePlayers.length > 1
const nextPlayerIndex = shouldSwitchPlayer
? (state.activePlayers.indexOf(state.currentPlayer) + 1) % state.activePlayers.length
: 0
const nextPlayer = shouldSwitchPlayer ? state.activePlayers[nextPlayerIndex] : state.currentPlayer
newState = {
...newState,
currentPlayer: nextPlayer,
consecutiveMatches: {
...state.consecutiveMatches,
[state.currentPlayer]: 0,
},
moves: state.moves + 1,
// Keep flippedCards so player can see both cards
flippedCards: newFlippedCards,
isProcessingMove: true, // Keep processing state so no more cards can be flipped
showMismatchFeedback: true,
}
}
}
return {
valid: true,
newState,
}
}
private validateStartGame(
state: MemoryPairsState,
activePlayers: number[],
cards?: GameCard[]
): ValidationResult {
// Allow starting a new game from any phase (for "New Game" button)
// Must have at least one player
if (!activePlayers || activePlayers.length === 0) {
return {
valid: false,
error: 'Must have at least one player',
}
}
// Use provided cards or generate new ones
const gameCards = cards || generateGameCards(state.gameType, state.difficulty)
const newState: MemoryPairsState = {
...state,
gameCards,
cards: gameCards,
activePlayers,
gamePhase: 'playing',
gameStartTime: Date.now(),
currentPlayer: activePlayers[0],
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
}
return {
valid: true,
newState,
}
}
private validateClearMismatch(state: MemoryPairsState): ValidationResult {
// Clear mismatched cards and feedback
return {
valid: true,
newState: {
...state,
flippedCards: [],
showMismatchFeedback: false,
isProcessingMove: false,
},
}
}
isGameComplete(state: MemoryPairsState): boolean {
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
}
getInitialState(config: {
difficulty: Difficulty
gameType: GameType
turnTimer: number
}): MemoryPairsState {
return {
cards: [],
gameCards: [],
flippedCards: [],
gameType: config.gameType,
difficulty: config.difficulty,
turnTimer: config.turnTimer,
gamePhase: 'setup',
currentPlayer: 1,
matchedPairs: 0,
totalPairs: config.difficulty,
moves: 0,
scores: {},
activePlayers: [],
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
}
}
}
// Singleton instance
export const matchingGameValidator = new MatchingGameValidator()

View File

@@ -0,0 +1,67 @@
/**
* Isomorphic game validation types
* Used on both client and server for arcade session validation
*/
import type { MemoryPairsState } from '@/app/games/matching/context/types'
export type GameName = 'matching' | 'memory-quiz' | 'complement-race'
export interface ValidationResult {
valid: boolean
error?: string
newState?: unknown
}
export interface GameMove {
type: string
playerId: string
timestamp: number
data: unknown
}
// Matching game specific moves
export interface MatchingFlipCardMove extends GameMove {
type: 'FLIP_CARD'
data: {
cardId: string
}
}
export interface MatchingStartGameMove extends GameMove {
type: 'START_GAME'
data: {
activePlayers: number[]
cards?: any[] // GameCard type from context
}
}
export interface MatchingClearMismatchMove extends GameMove {
type: 'CLEAR_MISMATCH'
data: Record<string, never>
}
export type MatchingGameMove = MatchingFlipCardMove | MatchingStartGameMove | MatchingClearMismatchMove
// Generic game state union
export type GameState = MemoryPairsState // Add other game states as union later
/**
* Base validator interface that all games must implement
*/
export interface GameValidator<TState = unknown, TMove extends GameMove = GameMove> {
/**
* Validate a game move and return the new state if valid
*/
validateMove(state: TState, move: TMove): ValidationResult
/**
* Check if the game is in a terminal state (completed)
*/
isGameComplete(state: TState): boolean
/**
* Get initial state for a new game
*/
getInitialState(config: unknown): TState
}