feat(arcade): migrate memory-quiz to modular game system

Create new modular structure for Memory Lightning game:
- New location: /src/arcade-games/memory-quiz/
- Game definition with manifest and config validation
- Unified Provider using useArcadeSession (room-mode only)
- Server-side Validator for move validation
- SDK-compatible types (Config, State, Moves)
- Registered in game-registry.ts

Key changes:
- Room-mode only (local mode deprecated)
- Type-safe config with InferGameConfig<>
- Action creators replace reducer pattern
- Optimistic client updates + server validation
- Config persistence to room_game_configs table

🤖 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-15 22:28:28 -05:00
parent 704f34f83e
commit f48c37accc
19 changed files with 399 additions and 528 deletions

View File

@@ -1,113 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useReducer } from 'react'
import { useRouter } from 'next/navigation'
import { initialState, quizReducer } from '../reducer'
import type { QuizCard } from '../types'
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
interface LocalMemoryQuizProviderProps {
children: ReactNode
}
/**
* LocalMemoryQuizProvider - Provides context for single-player local mode
*
* This provider wraps the memory quiz reducer and provides action creators
* to child components. It's used for standalone local play (non-room mode).
*
* Action creators wrap dispatch calls to maintain same interface as RoomProvider.
*/
export function LocalMemoryQuizProvider({ children }: LocalMemoryQuizProviderProps) {
const router = useRouter()
const [state, dispatch] = useReducer(quizReducer, initialState)
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (state.prefixAcceptanceTimeout) {
clearTimeout(state.prefixAcceptanceTimeout)
}
}
}, [state.prefixAcceptanceTimeout])
// Computed values
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
// Action creators - wrap dispatch calls to match RoomProvider interface
const startQuiz = useCallback((quizCards: QuizCard[]) => {
dispatch({ type: 'START_QUIZ', quizCards })
}, [])
const nextCard = useCallback(() => {
dispatch({ type: 'NEXT_CARD' })
}, [])
const showInputPhase = useCallback(() => {
dispatch({ type: 'SHOW_INPUT_PHASE' })
}, [])
const acceptNumber = useCallback((number: number) => {
dispatch({ type: 'ACCEPT_NUMBER', number })
}, [])
const rejectNumber = useCallback(() => {
dispatch({ type: 'REJECT_NUMBER' })
}, [])
const setInput = useCallback((input: string) => {
dispatch({ type: 'SET_INPUT', input })
}, [])
const showResults = useCallback(() => {
dispatch({ type: 'SHOW_RESULTS' })
}, [])
const resetGame = useCallback(() => {
dispatch({ type: 'RESET_QUIZ' })
}, [])
const setConfig = useCallback(
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => {
switch (field) {
case 'selectedCount':
dispatch({ type: 'SET_SELECTED_COUNT', count: value })
break
case 'displayTime':
dispatch({ type: 'SET_DISPLAY_TIME', time: value })
break
case 'selectedDifficulty':
dispatch({ type: 'SET_DIFFICULTY', difficulty: value })
break
}
},
[]
)
const exitSession = useCallback(() => {
router.push('/games')
}, [router])
const contextValue: MemoryQuizContextValue = {
state,
dispatch: () => {
// No-op - local provider uses action creators instead
console.warn('dispatch() is not available in local mode, use action creators instead')
},
isGameActive,
resetGame,
exitSession,
// Expose action creators for components to use
startQuiz,
nextCard,
showInputPhase,
acceptNumber,
rejectNumber,
setInput,
showResults,
setConfig,
}
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
}

View File

@@ -1,45 +0,0 @@
'use client'
import { createContext, useContext } from 'react'
import type { QuizAction, QuizCard, SorobanQuizState } from '../types'
// Context value interface
export interface MemoryQuizContextValue {
state: SorobanQuizState
dispatch: React.Dispatch<QuizAction>
// Computed values
isGameActive: boolean
isRoomCreator?: boolean // True if current user is room creator (controls timing in multiplayer)
// Action creators (to be implemented by providers)
// Local mode uses dispatch, room mode uses these action creators
startGame?: () => void
resetGame?: () => void
exitSession?: () => void
// Room mode action creators (optional for local mode)
startQuiz?: (quizCards: QuizCard[]) => void
nextCard?: () => void
showInputPhase?: () => void
acceptNumber?: (number: number) => void
rejectNumber?: () => void
setInput?: (input: string) => void
showResults?: () => void
setConfig?: (
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: any
) => void
}
// Create context
export const MemoryQuizContext = createContext<MemoryQuizContextValue | null>(null)
// Hook to use the context
export function useMemoryQuiz(): MemoryQuizContextValue {
const context = useContext(MemoryQuizContext)
if (!context) {
throw new Error('useMemoryQuiz must be used within a MemoryQuizProvider')
}
return context
}

View File

@@ -1,138 +0,0 @@
import type { QuizAction, SorobanQuizState } from './types'
export const initialState: SorobanQuizState = {
cards: [],
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
displayTime: 2.0,
selectedCount: 5,
selectedDifficulty: 'easy', // Default to easy level
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
// Multiplayer state
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: 'cooperative', // Default to cooperative
numberFoundBy: {},
// UI state
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
wrongGuessAnimations: [],
// Keyboard state (persistent across re-renders)
hasPhysicalKeyboard: null,
testingMode: false,
showOnScreenKeyboard: false,
}
export function quizReducer(state: SorobanQuizState, action: QuizAction): SorobanQuizState {
switch (action.type) {
case 'SET_CARDS':
return { ...state, cards: action.cards }
case 'SET_DISPLAY_TIME':
return { ...state, displayTime: action.time }
case 'SET_SELECTED_COUNT':
return { ...state, selectedCount: action.count }
case 'SET_DIFFICULTY':
return { ...state, selectedDifficulty: action.difficulty }
case 'SET_PLAY_MODE':
return { ...state, playMode: action.playMode }
case 'START_QUIZ':
return {
...state,
quizCards: action.quizCards,
correctAnswers: action.quizCards.map((card) => card.number),
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: action.quizCards.length + Math.floor(action.quizCards.length / 2),
gamePhase: 'display',
}
case 'NEXT_CARD':
return { ...state, currentCardIndex: state.currentCardIndex + 1 }
case 'SHOW_INPUT_PHASE':
return { ...state, gamePhase: 'input' }
case 'ACCEPT_NUMBER': {
// In competitive mode, track which player guessed correctly
const newPlayerScores = { ...state.playerScores }
if (state.playMode === 'competitive' && action.playerId) {
const currentScore = newPlayerScores[action.playerId] || { correct: 0, incorrect: 0 }
newPlayerScores[action.playerId] = {
...currentScore,
correct: currentScore.correct + 1,
}
}
return {
...state,
foundNumbers: [...state.foundNumbers, action.number],
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'REJECT_NUMBER': {
// In competitive mode, track which player guessed incorrectly
const newPlayerScores = { ...state.playerScores }
if (state.playMode === 'competitive' && action.playerId) {
const currentScore = newPlayerScores[action.playerId] || { correct: 0, incorrect: 0 }
newPlayerScores[action.playerId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'SET_INPUT':
return { ...state, currentInput: action.input }
case 'SET_PREFIX_TIMEOUT':
return { ...state, prefixAcceptanceTimeout: action.timeout }
case 'ADD_WRONG_GUESS_ANIMATION':
return {
...state,
wrongGuessAnimations: [
...state.wrongGuessAnimations,
{
number: action.number,
id: `wrong-${action.number}-${Date.now()}`,
timestamp: Date.now(),
},
],
}
case 'CLEAR_WRONG_GUESS_ANIMATIONS':
return {
...state,
wrongGuessAnimations: [],
}
case 'SHOW_RESULTS':
return { ...state, gamePhase: 'results' }
case 'RESET_QUIZ':
return {
...initialState,
cards: state.cards, // Preserve generated cards
displayTime: state.displayTime,
selectedCount: state.selectedCount,
selectedDifficulty: state.selectedDifficulty,
playMode: state.playMode, // Preserve play mode
// Preserve keyboard state across resets
hasPhysicalKeyboard: state.hasPhysicalKeyboard,
testingMode: state.testingMode,
showOnScreenKeyboard: state.showOnScreenKeyboard,
}
case 'SET_PHYSICAL_KEYBOARD':
return { ...state, hasPhysicalKeyboard: action.hasKeyboard }
case 'SET_TESTING_MODE':
return { ...state, testingMode: action.enabled }
case 'TOGGLE_ONSCREEN_KEYBOARD':
return { ...state, showOnScreenKeyboard: !state.showOnScreenKeyboard }
default:
return state
}
}

View File

@@ -1,32 +1,31 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import type { GameMove } from '@/lib/arcade/validation'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import {
buildPlayerMetadata as buildPlayerMetadataUtil,
buildPlayerOwnershipFromRoomData,
} from '@/lib/arcade/player-ownership.client'
import { initialState } from '../reducer'
import type { QuizCard, SorobanQuizState } from '../types'
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import type { QuizCard, MemoryQuizState, MemoryQuizMove } from './types'
import type { GameMove } from '@/lib/arcade/validation'
/**
* Optimistic move application (client-side prediction)
* The server will validate and send back the authoritative state
*/
function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): SorobanQuizState {
switch (move.type) {
function applyMoveOptimistically(state: MemoryQuizState, move: GameMove): MemoryQuizState {
const typedMove = move as MemoryQuizMove
switch (typedMove.type) {
case 'START_QUIZ': {
// Handle both client-generated moves (with quizCards) and server-generated moves (with numbers only)
// Server can't serialize React components, so it only sends numbers
const clientQuizCards = move.data.quizCards
const serverNumbers = move.data.numbers
const clientQuizCards = typedMove.data.quizCards
const serverNumbers = typedMove.data.numbers
let quizCards: QuizCard[]
let correctAnswers: number[]
@@ -36,7 +35,7 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
quizCards = clientQuizCards
correctAnswers = clientQuizCards.map((card: QuizCard) => card.number)
} else if (serverNumbers) {
// Server update: create minimal quizCards from numbers (no React components needed for validation)
// Server update: create minimal quizCards from numbers
quizCards = serverNumbers.map((number: number) => ({
number,
svgComponent: null,
@@ -44,18 +43,16 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
}))
correctAnswers = serverNumbers
} else {
// Fallback: preserve existing state
quizCards = state.quizCards
correctAnswers = state.correctAnswers
}
const cardCount = quizCards.length
// Initialize player scores for all active players (by userId, not playerId)
const activePlayers = move.data.activePlayers || []
const playerMetadata = move.data.playerMetadata || {}
// Initialize player scores for all active players (by userId)
const activePlayers = typedMove.data.activePlayers || []
const playerMetadata = typedMove.data.playerMetadata || {}
// Extract unique userIds from playerMetadata
const uniqueUserIds = new Set<string>()
for (const playerId of activePlayers) {
const metadata = playerMetadata[playerId]
@@ -64,11 +61,13 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
}
}
// Initialize scores for each userId
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
}, {})
const playerScores = Array.from(uniqueUserIds).reduce(
(acc: Record<string, { correct: number; incorrect: number }>, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
},
{}
)
return {
...state,
@@ -82,10 +81,10 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
// Multiplayer state
activePlayers,
playerMetadata,
playerScores,
numberFoundBy: {},
}
}
@@ -102,8 +101,6 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
}
case 'ACCEPT_NUMBER': {
// Track scores by userId (not playerId) since we can't determine which player typed
// Defensive check: ensure state properties exist
const playerScores = state.playerScores || {}
const foundNumbers = state.foundNumbers || []
const numberFoundBy = state.numberFoundBy || {}
@@ -111,44 +108,51 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
const newPlayerScores = { ...playerScores }
const newNumberFoundBy = { ...numberFoundBy }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
if (typedMove.userId) {
const currentScore = newPlayerScores[typedMove.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[typedMove.userId] = {
...currentScore,
correct: currentScore.correct + 1,
}
// Track who found this number
newNumberFoundBy[move.data.number] = move.userId
newNumberFoundBy[typedMove.data.number] = typedMove.userId
}
return {
...state,
foundNumbers: [...foundNumbers, move.data.number],
foundNumbers: [...foundNumbers, typedMove.data.number],
currentInput: '',
playerScores: newPlayerScores,
numberFoundBy: newNumberFoundBy,
}
}
case 'REJECT_NUMBER': {
// Track scores by userId (not playerId) since we can't determine which player typed
// Defensive check: ensure state properties exist
const playerScores = state.playerScores || {}
const newPlayerScores = { ...playerScores }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
if (typedMove.userId) {
const currentScore = newPlayerScores[typedMove.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[typedMove.userId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'SET_INPUT':
return {
...state,
currentInput: typedMove.data.input,
}
case 'SHOW_RESULTS':
return {
...state,
@@ -172,10 +176,7 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
}
case 'SET_CONFIG': {
const { field, value } = move.data as {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
value: any
}
const { field, value } = typedMove.data
return {
...state,
[field]: value,
@@ -187,65 +188,115 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
}
}
// Context interface
export interface MemoryQuizContextValue {
state: MemoryQuizState
isGameActive: boolean
isRoomCreator: boolean
resetGame: () => void
exitSession?: () => void
startQuiz: (quizCards: QuizCard[]) => void
nextCard: () => void
showInputPhase: () => void
acceptNumber: (number: number) => void
rejectNumber: () => void
setInput: (input: string) => void
showResults: () => void
setConfig: (
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: unknown
) => void
// Legacy dispatch for UI-only actions (to be migrated to local state)
dispatch: (action: unknown) => void
}
// Create context
const MemoryQuizContext = createContext<MemoryQuizContextValue | null>(null)
// Hook to use the context
export function useMemoryQuiz(): MemoryQuizContextValue {
const context = useContext(MemoryQuizContext)
if (!context) {
throw new Error('useMemoryQuiz must be used within MemoryQuizProvider')
}
return context
}
/**
* RoomMemoryQuizProvider - Provides context for room-based multiplayer mode
* MemoryQuizProvider - Unified provider for room-based multiplayer
*
* This provider uses useArcadeSession for network-synchronized gameplay.
* All state changes are sent as moves and validated on the server.
*/
export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
export function MemoryQuizProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active player IDs as array
const activePlayers = Array.from(activePlayerIds)
// LOCAL-ONLY state for current input (not synced over network)
// This prevents sending a network request for every keystroke
const [localCurrentInput, setLocalCurrentInput] = useState('')
// Merge saved game config from room with initialState
// Settings are scoped by game name to preserve settings when switching games
// Merge saved game config from room with default initial state
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null | undefined
if (!gameConfig) {
return initialState
const savedConfig = gameConfig?.['memory-quiz'] as Record<string, unknown> | null | undefined
// Default initial state
const defaultState: MemoryQuizState = {
cards: [],
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
displayTime: 2.0,
selectedCount: 5,
selectedDifficulty: 'easy',
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: 'cooperative',
numberFoundBy: {},
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
wrongGuessAnimations: [],
hasPhysicalKeyboard: null,
testingMode: false,
showOnScreenKeyboard: false,
}
// Get settings for this specific game (memory-quiz)
const savedConfig = gameConfig['memory-quiz'] as Record<string, any> | null | undefined
if (!savedConfig) {
return initialState
return defaultState
}
return {
...initialState,
// Restore settings from saved config
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
displayTime: savedConfig.displayTime ?? initialState.displayTime,
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig.playMode ?? initialState.playMode,
...defaultState,
selectedCount:
(savedConfig.selectedCount as 2 | 5 | 8 | 12 | 15) ?? defaultState.selectedCount,
displayTime: (savedConfig.displayTime as number) ?? defaultState.displayTime,
selectedDifficulty:
(savedConfig.selectedDifficulty as MemoryQuizState['selectedDifficulty']) ??
defaultState.selectedDifficulty,
playMode: (savedConfig.playMode as 'cooperative' | 'competitive') ?? defaultState.playMode,
}
}, [roomData?.gameConfig])
// Arcade session integration WITH room sync
const {
state,
sendMove,
connected: _connected,
exitSession,
} = useArcadeSession<SorobanQuizState>({
// Arcade session integration
const { state, sendMove, exitSession } = useArcadeSession<MemoryQuizState>({
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
roomId: roomData?.id || undefined,
initialState: mergedInitialState,
applyMove: applyMoveOptimistically,
})
// Clear local input when game phase changes or when game resets
// Clear local input when game phase changes
useEffect(() => {
if (state.gamePhase !== 'input') {
setLocalCurrentInput('')
@@ -261,7 +312,7 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
}
}, [state.prefixAcceptanceTimeout])
// Detect state corruption/mismatch (e.g., game type mismatch between sessions)
// Detect state corruption
const hasStateCorruption =
!state.quizCards ||
!state.correctAnswers ||
@@ -271,32 +322,33 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
// Computed values
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
// Build player metadata from room data and player map
// Build player metadata
const buildPlayerMetadata = useCallback(() => {
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
const metadata = buildPlayerMetadataUtil(activePlayers, playerOwnership, players, viewerId)
const metadata = buildPlayerMetadataUtil(
activePlayers,
playerOwnership,
players,
viewerId || undefined
)
return metadata
}, [activePlayers, players, roomData, viewerId])
// Action creators - send moves to arcade session
// Action creators
const startQuiz = useCallback(
(quizCards: QuizCard[]) => {
// Extract only serializable data (numbers) for server
// React components can't be sent over Socket.IO
const numbers = quizCards.map((card) => card.number)
// Build player metadata for multiplayer
const playerMetadata = buildPlayerMetadata()
sendMove({
type: 'START_QUIZ',
playerId: TEAM_MOVE, // Team move - all players act together
userId: viewerId || '', // User who initiated
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {
numbers, // Send to server
quizCards, // Keep for optimistic local update
activePlayers, // Send active players list
playerMetadata, // Send player display info
numbers,
quizCards,
activePlayers,
playerMetadata,
},
})
},
@@ -323,13 +375,11 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const acceptNumber = useCallback(
(number: number) => {
// Clear local input immediately
setLocalCurrentInput('')
sendMove({
type: 'ACCEPT_NUMBER',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed correctly
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { number },
})
},
@@ -337,20 +387,17 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
)
const rejectNumber = useCallback(() => {
// Clear local input immediately
setLocalCurrentInput('')
sendMove({
type: 'REJECT_NUMBER',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed incorrectly
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const setInput = useCallback((input: string) => {
// LOCAL ONLY - no network sync!
// This makes typing instant with zero network lag
// LOCAL ONLY - no network sync for instant typing
setLocalCurrentInput(input)
}, [])
@@ -373,9 +420,10 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
}, [viewerId, sendMove])
const setConfig = useCallback(
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode', value: any) => {
console.log(`[RoomMemoryQuizProvider] setConfig called: ${field} = ${value}`)
(
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: unknown
) => {
sendMove({
type: 'SET_CONFIG',
playerId: TEAM_MOVE,
@@ -383,12 +431,11 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
data: { field, value },
})
// Save setting to room's gameConfig for persistence
// Settings are scoped by game name to preserve settings when switching games
// Save to room config for persistence
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
const currentMemoryQuizConfig =
(currentGameConfig['memory-quiz'] as Record<string, any>) || {}
(currentGameConfig['memory-quiz'] as Record<string, unknown>) || {}
updateGameConfig({
roomId: roomData.id,
@@ -405,13 +452,27 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
[viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
// Merge network state with local input state
// Legacy dispatch stub for UI-only actions
// TODO: Migrate these to local component state
const dispatch = useCallback((action: unknown) => {
console.warn(
'[MemoryQuizProvider] dispatch() is deprecated for UI-only actions. These should be migrated to local component state:',
action
)
// No-op - UI-only state changes should be handled locally
}, [])
// Merge network state with local input
const mergedState = {
...state,
currentInput: localCurrentInput, // Override network state with local input
currentInput: localCurrentInput,
}
// If state is corrupted, show error message instead of crashing
// Determine if current user is room creator
const isRoomCreator =
roomData?.members.find((member) => member.userId === viewerId)?.isCreator || false
// Handle state corruption
if (hasStateCorruption) {
return (
<div
@@ -425,68 +486,18 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
minHeight: '400px',
}}
>
<div
style={{
fontSize: '48px',
marginBottom: '20px',
}}
>
</div>
<div style={{ fontSize: '48px', marginBottom: '20px' }}></div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#dc2626',
}}
style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '12px', color: '#dc2626' }}
>
Game State Mismatch
</h2>
<p
style={{
fontSize: '16px',
color: '#6b7280',
marginBottom: '24px',
maxWidth: '500px',
}}
>
<p style={{ fontSize: '16px', color: '#6b7280', marginBottom: '24px', maxWidth: '500px' }}>
There's a mismatch between game types in this room. This usually happens when room members
are playing different games.
</p>
<div
style={{
background: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px',
maxWidth: '500px',
}}
>
<p
style={{
fontSize: '14px',
fontWeight: '600',
marginBottom: '8px',
}}
>
To fix this:
</p>
<ol
style={{
fontSize: '14px',
textAlign: 'left',
paddingLeft: '20px',
lineHeight: '1.6',
}}
>
<li>Make sure all room members are on the same game page</li>
<li>Try refreshing the page</li>
<li>If the issue persists, leave and rejoin the room</li>
</ol>
</div>
<button
type="button"
onClick={() => window.location.reload()}
style={{
padding: '10px 20px',
@@ -505,21 +516,12 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
)
}
// Determine if current user is the room creator (controls card timing)
const isRoomCreator =
roomData?.members.find((member) => member.userId === viewerId)?.isCreator || false
const contextValue: MemoryQuizContextValue = {
state: mergedState,
dispatch: () => {
// No-op - replaced with action creators
console.warn('dispatch() is deprecated in room mode, use action creators instead')
},
isGameActive,
resetGame,
exitSession,
isRoomCreator, // Pass room creator flag to components
// Expose action creators for components to use
isRoomCreator,
startQuiz,
nextCard,
showInputPhase,
@@ -528,10 +530,8 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
setInput,
showResults,
setConfig,
dispatch,
}
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
}
// Export the hook for this provider
export { useMemoryQuiz } from './MemoryQuizContext'

View File

@@ -3,21 +3,18 @@
* Validates all game moves and state transitions
*/
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
import type {
GameValidator,
MemoryQuizGameMove,
MemoryQuizConfig,
MemoryQuizState,
MemoryQuizMove,
MemoryQuizSetConfigMove,
ValidationResult,
} from './types'
export class MemoryQuizGameValidator
implements GameValidator<SorobanQuizState, MemoryQuizGameMove>
{
export class MemoryQuizGameValidator implements GameValidator<MemoryQuizState, MemoryQuizMove> {
validateMove(
state: SorobanQuizState,
move: MemoryQuizGameMove,
state: MemoryQuizState,
move: MemoryQuizMove,
context?: { userId?: string; playerOwnership?: Record<string, string> }
): ValidationResult {
switch (move.type) {
@@ -58,7 +55,7 @@ export class MemoryQuizGameValidator
}
}
private validateStartQuiz(state: SorobanQuizState, data: any): ValidationResult {
private validateStartQuiz(state: MemoryQuizState, data: any): ValidationResult {
// Can start quiz from setup or results phase
if (state.gamePhase !== 'setup' && state.gamePhase !== 'results') {
return {
@@ -102,7 +99,7 @@ export class MemoryQuizGameValidator
return acc
}, {})
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
quizCards,
correctAnswers: numbers,
@@ -127,7 +124,7 @@ export class MemoryQuizGameValidator
}
}
private validateNextCard(state: SorobanQuizState): ValidationResult {
private validateNextCard(state: MemoryQuizState): ValidationResult {
// Must be in display phase
if (state.gamePhase !== 'display') {
return {
@@ -136,7 +133,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
currentCardIndex: state.currentCardIndex + 1,
}
@@ -147,7 +144,7 @@ export class MemoryQuizGameValidator
}
}
private validateShowInputPhase(state: SorobanQuizState): ValidationResult {
private validateShowInputPhase(state: MemoryQuizState): ValidationResult {
// Must have shown all cards
if (state.currentCardIndex < state.quizCards.length) {
return {
@@ -156,7 +153,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
gamePhase: 'input',
}
@@ -168,7 +165,7 @@ export class MemoryQuizGameValidator
}
private validateAcceptNumber(
state: SorobanQuizState,
state: MemoryQuizState,
number: number,
userId?: string
): ValidationResult {
@@ -212,7 +209,7 @@ export class MemoryQuizGameValidator
newNumberFoundBy[number] = userId
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
foundNumbers: [...state.foundNumbers, number],
currentInput: '',
@@ -226,7 +223,7 @@ export class MemoryQuizGameValidator
}
}
private validateRejectNumber(state: SorobanQuizState, userId?: string): ValidationResult {
private validateRejectNumber(state: MemoryQuizState, userId?: string): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
@@ -254,7 +251,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
@@ -268,7 +265,7 @@ export class MemoryQuizGameValidator
}
}
private validateSetInput(state: SorobanQuizState, input: string): ValidationResult {
private validateSetInput(state: MemoryQuizState, input: string): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
@@ -285,7 +282,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
currentInput: input,
}
@@ -296,7 +293,7 @@ export class MemoryQuizGameValidator
}
}
private validateShowResults(state: SorobanQuizState): ValidationResult {
private validateShowResults(state: MemoryQuizState): ValidationResult {
// Can show results from input phase
if (state.gamePhase !== 'input') {
return {
@@ -305,7 +302,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
gamePhase: 'results',
}
@@ -316,9 +313,9 @@ export class MemoryQuizGameValidator
}
}
private validateResetQuiz(state: SorobanQuizState): ValidationResult {
private validateResetQuiz(state: MemoryQuizState): ValidationResult {
// Can reset from any phase
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
gamePhase: 'setup',
quizCards: [],
@@ -340,7 +337,7 @@ export class MemoryQuizGameValidator
}
private validateSetConfig(
state: SorobanQuizState,
state: MemoryQuizState,
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: any
): ValidationResult {
@@ -392,11 +389,11 @@ export class MemoryQuizGameValidator
}
}
isGameComplete(state: SorobanQuizState): boolean {
isGameComplete(state: MemoryQuizState): boolean {
return state.gamePhase === 'results'
}
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
getInitialState(config: MemoryQuizConfig): MemoryQuizState {
return {
cards: [],
quizCards: [],

View File

@@ -1,8 +1,8 @@
import { AbacusReact } from '@soroban/abacus-react'
import type { SorobanQuizState } from '../types'
import type { MemoryQuizState } from '../types'
interface CardGridProps {
state: SorobanQuizState
state: MemoryQuizState
}
export function CardGrid({ state }: CardGridProps) {

View File

@@ -1,6 +1,6 @@
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { useMemoryQuiz } from '../Provider'
import type { QuizCard } from '../types'
// Calculate maximum columns needed for a set of numbers

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { isPrefix } from '@/lib/memory-quiz-utils'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { useMemoryQuiz } from '../Provider'
import { CardGrid } from './CardGrid'
export function InputPhase() {

View File

@@ -3,8 +3,8 @@
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../../styled-system/css'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { css } from '../../../../styled-system/css'
import { useMemoryQuiz } from '../Provider'
import { DisplayPhase } from './DisplayPhase'
import { InputPhase } from './InputPhase'
import { ResultsPhase } from './ResultsPhase'

View File

@@ -1,8 +1,8 @@
import { AbacusReact } from '@soroban/abacus-react'
import type { SorobanQuizState } from '../types'
import type { MemoryQuizState } from '../types'
interface ResultsCardGridProps {
state: SorobanQuizState
state: MemoryQuizState
}
export function ResultsCardGrid({ state }: ResultsCardGridProps) {

View File

@@ -1,5 +1,5 @@
import { useAbacusConfig } from '@soroban/abacus-react'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { useMemoryQuiz } from '../Provider'
import { DIFFICULTY_LEVELS, type DifficultyLevel, type QuizCard } from '../types'
import { ResultsCardGrid } from './ResultsCardGrid'
@@ -384,7 +384,7 @@ export function ResultsPhase() {
// Group players by userId
const userTeams = new Map<
string,
{ userId: string; players: any[]; score: { correct: number; incorrect: 0 } }
{ userId: string; players: any[]; score: { correct: number; incorrect: number } }
>()
console.log('🤝 [ResultsPhase] Building team contributions:', {

View File

@@ -1,5 +1,5 @@
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { useMemoryQuiz } from '../Provider'
import { DIFFICULTY_LEVELS, type DifficultyLevel, type QuizCard } from '../types'
// Generate quiz cards with difficulty-based number ranges

View File

@@ -0,0 +1,85 @@
/**
* Memory Quiz (Memory Lightning) Game Definition
*
* A memory game where players memorize soroban numbers and recall them.
* Supports both cooperative and competitive multiplayer modes.
*/
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { MemoryQuizGame } from './components/MemoryQuizGame'
import { MemoryQuizProvider } from './Provider'
import type { MemoryQuizConfig, MemoryQuizMove, MemoryQuizState } from './types'
import { memoryQuizGameValidator } from './Validator'
const manifest: GameManifest = {
name: 'memory-quiz',
displayName: 'Memory Lightning',
icon: '🧠',
description: 'Memorize soroban numbers and recall them',
longDescription:
'Test your memory by studying soroban numbers for a brief time, then recall as many as you can. ' +
'Choose your difficulty level, number of cards, and display time. Play cooperatively with friends or compete for the highest score!',
maxPlayers: 8,
difficulty: 'Intermediate',
chips: ['👥 Multiplayer', '🧠 Memory', '🧮 Soroban'],
color: 'blue',
gradient: 'linear-gradient(135deg, #dbeafe, #bfdbfe)',
borderColor: 'blue.200',
available: true,
}
const defaultConfig: MemoryQuizConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: 'easy',
playMode: 'cooperative',
}
// Config validation function
function validateMemoryQuizConfig(config: unknown): config is MemoryQuizConfig {
if (typeof config !== 'object' || config === null) {
return false
}
const c = config as any
// Validate selectedCount
if (!('selectedCount' in c) || ![2, 5, 8, 12, 15].includes(c.selectedCount)) {
return false
}
// Validate displayTime
if (
!('displayTime' in c) ||
typeof c.displayTime !== 'number' ||
c.displayTime < 0.5 ||
c.displayTime > 10
) {
return false
}
// Validate selectedDifficulty
if (
!('selectedDifficulty' in c) ||
!['beginner', 'easy', 'medium', 'hard', 'expert'].includes(c.selectedDifficulty)
) {
return false
}
// Validate playMode
if (!('playMode' in c) || !['cooperative', 'competitive'].includes(c.playMode)) {
return false
}
return true
}
export const memoryQuizGame = defineGame<MemoryQuizConfig, MemoryQuizState, MemoryQuizMove>({
manifest,
Provider: MemoryQuizProvider,
GameComponent: MemoryQuizGame,
validator: memoryQuizGameValidator,
defaultConfig,
validateConfig: validateMemoryQuizConfig,
})

View File

@@ -1,3 +1,4 @@
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk'
import type { PlayerMetadata } from '@/lib/arcade/player-ownership.client'
export interface QuizCard {
@@ -11,7 +12,16 @@ export interface PlayerScore {
incorrect: number
}
export interface SorobanQuizState {
// Memory Quiz Configuration
export interface MemoryQuizConfig extends GameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
}
// Memory Quiz State
export interface MemoryQuizState extends GameState {
// Core game data
cards: QuizCard[]
quizCards: QuizCard[]
@@ -52,6 +62,7 @@ export interface SorobanQuizState {
showOnScreenKeyboard: boolean
}
// Legacy reducer actions (deprecated - will be removed)
export type QuizAction =
| { type: 'SET_CARDS'; cards: QuizCard[] }
| { type: 'SET_DISPLAY_TIME'; time: number }
@@ -103,3 +114,79 @@ export const DIFFICULTY_LEVELS = {
} as const
export type DifficultyLevel = keyof typeof DIFFICULTY_LEVELS
// Memory Quiz Move Types (SDK-compatible)
export type MemoryQuizMove =
| {
type: 'START_QUIZ'
playerId: string
userId: string
timestamp: number
data: {
numbers: number[]
quizCards?: QuizCard[]
activePlayers: string[]
playerMetadata: Record<string, PlayerMetadata>
}
}
| {
type: 'NEXT_CARD'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SHOW_INPUT_PHASE'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'ACCEPT_NUMBER'
playerId: string
userId: string
timestamp: number
data: { number: number }
}
| {
type: 'REJECT_NUMBER'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SET_INPUT'
playerId: string
userId: string
timestamp: number
data: { input: string }
}
| {
type: 'SHOW_RESULTS'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'RESET_QUIZ'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SET_CONFIG'
playerId: string
userId: string
timestamp: number
data: {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
value: any
}
}
export type MemoryQuizSetConfigMove = Extract<MemoryQuizMove, { type: 'SET_CONFIG' }>

View File

@@ -201,7 +201,7 @@ export function validateGameConfig(gameName: ExtendedGameName, config: any): boo
const game = getGame(gameName)
// If game has a validateConfig function, use it
if (game?.validateConfig) {
if (game && game.validateConfig) {
return game.validateConfig(config)
}

View File

@@ -2,8 +2,8 @@
* Shared game configuration types
*
* ARCHITECTURE: Phase 3 - Type Inference
* - Modern games (number-guesser, math-sprint): Types inferred from game definitions
* - Legacy games (matching, memory-quiz, complement-race): Manual types until migrated
* - Modern games (number-guesser, math-sprint, memory-quiz): Types inferred from game definitions
* - Legacy games (matching, complement-race): Manual types until migrated
*
* These types are used across:
* - Database storage (room_game_configs table)
@@ -12,12 +12,12 @@
* - Helper functions (reading/writing configs)
*/
import type { DifficultyLevel } from '@/app/arcade/memory-quiz/types'
import type { Difficulty, GameType } from '@/app/games/matching/context/types'
// Type-only imports (won't load React components at runtime)
import type { numberGuesserGame } from '@/arcade-games/number-guesser'
import type { mathSprintGame } from '@/arcade-games/math-sprint'
import type { memoryQuizGame } from '@/arcade-games/memory-quiz'
/**
* Utility type: Extract config type from a game definition
@@ -41,6 +41,12 @@ export type NumberGuesserGameConfig = InferGameConfig<typeof numberGuesserGame>
*/
export type MathSprintGameConfig = InferGameConfig<typeof mathSprintGame>
/**
* Configuration for memory-quiz (soroban lightning) game
* INFERRED from memoryQuizGame.defaultConfig
*/
export type MemoryQuizGameConfig = InferGameConfig<typeof memoryQuizGame>
// ============================================================================
// Legacy Games (Manual Type Definitions)
// TODO: Migrate these games to the modular system for type inference
@@ -55,16 +61,6 @@ export interface MatchingGameConfig {
turnTimer: number
}
/**
* Configuration for memory-quiz (soroban lightning) game
*/
export interface MemoryQuizGameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
}
/**
* Configuration for complement-race game
* TODO: Define when implementing complement-race settings
@@ -83,14 +79,14 @@ export interface ComplementRaceGameConfig {
* Modern games use inferred types, legacy games use manual types
*/
export type GameConfigByName = {
// Legacy games (manual types)
matching: MatchingGameConfig
'memory-quiz': MemoryQuizGameConfig
'complement-race': ComplementRaceGameConfig
// Modern games (inferred types)
'number-guesser': NumberGuesserGameConfig
'math-sprint': MathSprintGameConfig
'memory-quiz': MemoryQuizGameConfig
// Legacy games (manual types)
matching: MatchingGameConfig
'complement-race': ComplementRaceGameConfig
}
/**

View File

@@ -108,6 +108,8 @@ export function clearRegistry(): void {
import { numberGuesserGame } from '@/arcade-games/number-guesser'
import { mathSprintGame } from '@/arcade-games/math-sprint'
import { memoryQuizGame } from '@/arcade-games/memory-quiz'
registerGame(numberGuesserGame)
registerGame(mathSprintGame)
registerGame(memoryQuizGame)

View File

@@ -4,7 +4,7 @@
*/
import type { MemoryPairsState } from '@/app/games/matching/context/types'
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
import type { MemoryQuizState as SorobanQuizState } from '@/arcade-games/memory-quiz/types'
/**
* Game name type - auto-derived from validator registry

View File

@@ -11,7 +11,7 @@
*/
import { matchingGameValidator } from './validation/MatchingGameValidator'
import { memoryQuizGameValidator } from './validation/MemoryQuizGameValidator'
import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator'
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
import { mathSprintValidator } from '@/arcade-games/math-sprint/Validator'
import type { GameValidator } from './validation/types'