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:
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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: [],
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
@@ -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() {
|
||||
@@ -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'
|
||||
@@ -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) {
|
||||
@@ -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:', {
|
||||
@@ -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
|
||||
85
apps/web/src/arcade-games/memory-quiz/index.ts
Normal file
85
apps/web/src/arcade-games/memory-quiz/index.ts
Normal 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,
|
||||
})
|
||||
@@ -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' }>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user