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:
@@ -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
|
||||
}
|
||||
239
apps/web/src/lib/arcade/validation/MatchingGameValidator.ts
Normal file
239
apps/web/src/lib/arcade/validation/MatchingGameValidator.ts
Normal 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()
|
||||
67
apps/web/src/lib/arcade/validation/types.ts
Normal file
67
apps/web/src/lib/arcade/validation/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user