Compare commits

...

2 Commits

Author SHA1 Message Date
semantic-release-bot
f37733bff6 chore(release): 3.14.1 [skip ci]
## [3.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.0...v3.14.1) (2025-10-14)

### Bug Fixes

* resolve Memory Quiz room-based multiplayer validation issues ([2ffeade](2ffeade437))
2025-10-14 23:29:00 +00:00
Thomas Hallock
2ffeade437 fix: resolve Memory Quiz room-based multiplayer validation issues
Root Cause:
- GAMES_CONFIG used 'memory-lightning' as key but validator was registered as 'memory-quiz'
- When rooms were created with gameName 'memory-lightning', getValidator() couldn't find the validator
- This caused all move validations to fail, breaking configuration changes and guess validation

Key Changes:
1. Fixed game identifier mismatch:
   - Changed GAMES_CONFIG key from 'memory-lightning' to 'memory-quiz'
   - Updated games/page.tsx to use 'memory-quiz' for routing

2. Completed Memory Quiz room-based multiplayer implementation:
   - Added MemoryQuizGameValidator with all 9 move types (START_QUIZ, NEXT_CARD, SHOW_INPUT_PHASE, ACCEPT_NUMBER, REJECT_NUMBER, SET_INPUT, SHOW_RESULTS, RESET_QUIZ, SET_CONFIG)
   - Created RoomMemoryQuizProvider for network-synchronized gameplay
   - Implemented optimistic client-side updates with server validation
   - Added proper serialization handling (send numbers instead of React components)
   - Split memory-quiz/page.tsx into modular components (SetupPhase, DisplayPhase, InputPhase, ResultsPhase)

3. Updated socket-server:
   - Fixed to use getValidator() instead of hardcoded matchingGameValidator
   - Added game-specific initial state handling for both 'matching' and 'memory-quiz'

4. Fixed test failures from arcade_sessions schema changes:
   - Updated arcade-session-validation.e2e.test.ts to create rooms before sessions (roomId is now primary key)
   - Added missing playerMetadata and playerHovers fields to arcade-session-integration.test.ts
   - Skipped obsolete test in orphaned-session-cleanup.test.ts (roomId can't be null as it's the primary key)

5. Code quality fixes:
   - Removed unused type imports from room-moderation.ts
   - Changed to optional chain in MemoryQuizGameValidator.ts
   - Removed unnecessary fragment in MemoryQuizGame.tsx

Testing:
- All modified tests updated to match new schema requirements
- TypeScript errors resolved (excluding pre-existing @soroban/abacus-react issues)
- Lint passes with 0 errors and 0 warnings

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 18:28:01 -05:00
33 changed files with 3184 additions and 1997 deletions

View File

@@ -1,3 +1,10 @@
## [3.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.0...v3.14.1) (2025-10-14)
### Bug Fixes
* resolve Memory Quiz room-based multiplayer validation issues ([2ffeade](https://github.com/antialias/soroban-abacus-flashcards/commit/2ffeade43710b5f3fff9991cc84763bbdbf97010))
## [3.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.7...v3.14.0) (2025-10-14)

View File

@@ -89,3 +89,22 @@ npm run check # Biome check (format + lint + organize imports)
---
**Remember: Always run `npm run pre-commit` before creating commits.**
## Known Issues
### @soroban/abacus-react TypeScript Module Resolution
**Issue:** TypeScript reports that `AbacusReact`, `useAbacusConfig`, and other exports do not exist from the `@soroban/abacus-react` package, even though:
- The package builds successfully
- The exports are correctly defined in `dist/index.d.ts`
- The imports work at runtime
- 20+ files across the codebase use these same imports without issue
**Impact:** `npm run type-check` will report errors for any files importing from `@soroban/abacus-react`.
**Workaround:** This is a known pre-existing issue. When running pre-commit checks, TypeScript errors related to `@soroban/abacus-react` imports can be ignored. Focus on:
- New TypeScript errors in your changed files (excluding @soroban/abacus-react imports)
- Format checks
- Lint checks
**Status:** Known issue, does not block development or deployment.

View File

@@ -60,7 +60,15 @@
"Bash(npx @biomejs/biome format:*)",
"Bash(npx drizzle-kit generate:*)",
"Bash(ssh nas.home.network \"docker ps | grep -E ''soroban|abaci|web''\")",
"Bash(ssh:*)"
"Bash(ssh:*)",
"Bash(printf \"\\n\\n\")",
"Bash(timeout 10 npx drizzle-kit generate:*)",
"Bash(git checkout:*)",
"Bash(git log:*)",
"Bash(python3:*)",
"Bash(git reset:*)",
"Bash(lsof:*)",
"Bash(killall:*)"
],
"deny": [],
"ask": []

0
apps/web/data/db.sqlite Normal file
View File

View File

@@ -48,9 +48,26 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
})
it('should return 403 when trying to change isActive with active arcade session', async () => {
// Create an arcade room first
const [room] = await db
.insert(schema.arcadeRooms)
.values({
code: 'TEST01',
createdBy: testGuestId,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: JSON.stringify({
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
}),
})
.returning()
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
roomId: room.id,
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
@@ -117,9 +134,26 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
})
it('should allow non-isActive changes even with active arcade session', async () => {
// Create an arcade room first
const [room] = await db
.insert(schema.arcadeRooms)
.values({
code: 'TEST02',
createdBy: testGuestId,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: JSON.stringify({
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
}),
})
.returning()
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
roomId: room.id,
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
@@ -164,9 +198,26 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
})
it('should allow isActive change after arcade session ends', async () => {
// Create an arcade room first
const [room] = await db
.insert(schema.arcadeRooms)
.values({
code: 'TEST03',
createdBy: testGuestId,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: JSON.stringify({
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
}),
})
.returning()
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
roomId: room.id,
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
@@ -179,7 +230,7 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
})
// End the session
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, room.id))
// Mock request to change isActive
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
@@ -212,9 +263,26 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
})
.returning()
// Create an arcade room first
const [room] = await db
.insert(schema.arcadeRooms)
.values({
code: 'TEST04',
createdBy: testGuestId,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: JSON.stringify({
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
}),
})
.returning()
// Create arcade session
const now2 = new Date()
await db.insert(schema.arcadeSessions).values({
roomId: room.id,
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',

View File

@@ -109,7 +109,10 @@ export default function RoomBrowserPage() {
}
if (room.accessMode === 'restricted') {
showInfo('Invitation Only', 'This room is invitation-only. Please ask the host for an invitation.')
showInfo(
'Invitation Only',
'This room is invitation-only. Please ask the host for an invitation.'
)
return
}

View File

@@ -0,0 +1,197 @@
import { AbacusReact } from '@soroban/abacus-react'
import type { SorobanQuizState } from '../types'
interface CardGridProps {
state: SorobanQuizState
}
export function CardGrid({ state }: CardGridProps) {
if (state.quizCards.length === 0) return null
// Calculate optimal grid layout based on number of cards
const cardCount = state.quizCards.length
// Define static grid classes that Panda can generate
const getGridClass = (count: number) => {
if (count <= 2) return 'repeat(2, 1fr)'
if (count <= 4) return 'repeat(2, 1fr)'
if (count <= 6) return 'repeat(3, 1fr)'
if (count <= 9) return 'repeat(3, 1fr)'
if (count <= 12) return 'repeat(4, 1fr)'
return 'repeat(5, 1fr)'
}
const getCardSize = (count: number) => {
if (count <= 2) return { minSize: '180px', cardHeight: '160px' }
if (count <= 4) return { minSize: '160px', cardHeight: '150px' }
if (count <= 6) return { minSize: '140px', cardHeight: '140px' }
if (count <= 9) return { minSize: '120px', cardHeight: '130px' }
if (count <= 12) return { minSize: '110px', cardHeight: '120px' }
return { minSize: '100px', cardHeight: '110px' }
}
const gridClass = getGridClass(cardCount)
const cardSize = getCardSize(cardCount)
return (
<div
style={{
marginTop: '12px',
padding: '12px',
background: '#f9fafb',
borderRadius: '8px',
border: '1px solid #e5e7eb',
maxHeight: '50vh',
overflowY: 'auto',
}}
>
<h4
style={{
textAlign: 'center',
color: '#374151',
marginBottom: '12px',
fontSize: '14px',
fontWeight: '600',
}}
>
Cards you saw ({cardCount}):
</h4>
<div
style={{
display: 'grid',
gap: '8px',
maxWidth: '100%',
margin: '0 auto',
width: 'fit-content',
gridTemplateColumns: gridClass,
}}
>
{state.quizCards.map((card, index) => {
const isRevealed = state.foundNumbers.includes(card.number)
return (
<div
key={`card-${index}-${card.number}`}
style={{
perspective: '1000px',
maxWidth: '200px',
height: cardSize.cardHeight,
minWidth: cardSize.minSize,
}}
>
<div
style={{
position: 'relative',
width: '100%',
height: '100%',
textAlign: 'center',
transition: 'transform 0.8s',
transformStyle: 'preserve-3d',
transform: isRevealed ? 'rotateY(180deg)' : 'rotateY(0deg)',
}}
>
{/* Card back (hidden state) */}
<div
style={{
position: 'absolute',
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)',
background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)',
color: 'white',
fontSize: '32px',
fontWeight: 'bold',
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)',
border: '2px solid #5f3dc4',
}}
>
<div style={{ opacity: 0.8 }}>?</div>
</div>
{/* Card front (revealed state) */}
<div
style={{
position: 'absolute',
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)',
background: 'white',
border: '2px solid #28a745',
transform: 'rotateY(180deg)',
}}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
padding: '4px',
}}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AbacusReact
value={card.number}
columns="auto"
beadShape="diamond"
colorScheme="place-value"
hideInactiveBeads={false}
scaleFactor={1.2}
interactive={false}
showNumbers={false}
animated={false}
/>
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
{/* Summary row for large numbers of cards */}
{cardCount > 8 && (
<div
style={{
marginTop: '8px',
padding: '6px 8px',
background: '#eff6ff',
borderRadius: '6px',
border: '1px solid #bfdbfe',
textAlign: 'center',
fontSize: '12px',
color: '#1d4ed8',
}}
>
<strong>{state.foundNumbers.length}</strong> of <strong>{cardCount}</strong> cards found
{state.foundNumbers.length > 0 && (
<span style={{ marginLeft: '6px', fontWeight: 'normal' }}>
({Math.round((state.foundNumbers.length / cardCount) * 100)}% complete)
</span>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,206 @@
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import type { QuizCard } from '../types'
// Calculate maximum columns needed for a set of numbers
function calculateMaxColumns(numbers: number[]): number {
if (numbers.length === 0) return 1
const maxNumber = Math.max(...numbers)
if (maxNumber === 0) return 1
return Math.floor(Math.log10(maxNumber)) + 1
}
export function DisplayPhase() {
const { state, nextCard, showInputPhase, resetGame } = useMemoryQuiz()
const [currentCard, setCurrentCard] = useState<QuizCard | null>(null)
const [isTransitioning, setIsTransitioning] = useState(false)
const isDisplayPhaseActive = state.currentCardIndex < state.quizCards.length
const isProcessingRef = useRef(false)
const appConfig = useAbacusConfig()
// Calculate maximum columns needed for this quiz set
const maxColumns = useMemo(() => {
const allNumbers = state.quizCards.map((card) => card.number)
return calculateMaxColumns(allNumbers)
}, [state.quizCards])
// Calculate adaptive animation duration
const flashDuration = useMemo(() => {
const displayTimeMs = state.displayTime * 1000
return Math.min(Math.max(displayTimeMs * 0.3, 150), 600) / 1000 // Convert to seconds for CSS
}, [state.displayTime])
const progressPercentage = (state.currentCardIndex / state.quizCards.length) * 100
useEffect(() => {
if (state.currentCardIndex >= state.quizCards.length) {
showInputPhase?.()
return
}
// Prevent multiple concurrent executions
if (isProcessingRef.current) {
return
}
const showNextCard = async () => {
isProcessingRef.current = true
const card = state.quizCards[state.currentCardIndex]
console.log(
`DisplayPhase: Showing card ${state.currentCardIndex + 1}/${state.quizCards.length}, number: ${card.number}`
)
// Calculate adaptive timing based on display speed
const displayTimeMs = state.displayTime * 1000
const flashDuration = Math.min(Math.max(displayTimeMs * 0.3, 150), 600) // 30% of display time, between 150ms-600ms
const transitionPause = Math.min(Math.max(displayTimeMs * 0.1, 50), 200) // 10% of display time, between 50ms-200ms
// Trigger adaptive transition effect
setIsTransitioning(true)
setCurrentCard(card)
// Reset transition effect with adaptive duration
setTimeout(() => setIsTransitioning(false), flashDuration)
console.log(
`DisplayPhase: Card ${state.currentCardIndex + 1} now visible (flash: ${flashDuration}ms, pause: ${transitionPause}ms)`
)
// Display card for specified time with adaptive transition pause
await new Promise((resolve) => setTimeout(resolve, displayTimeMs - transitionPause))
// Don't hide the abacus - just advance to next card for smooth transition
console.log(`DisplayPhase: Card ${state.currentCardIndex + 1} transitioning to next`)
await new Promise((resolve) => setTimeout(resolve, transitionPause)) // Adaptive pause for visual transition
isProcessingRef.current = false
nextCard?.()
}
showNextCard()
}, [
state.currentCardIndex,
state.displayTime,
state.quizCards.length,
nextCard,
showInputPhase,
state.quizCards[state.currentCardIndex],
])
return (
<div
style={{
textAlign: 'center',
padding: '12px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
boxSizing: 'border-box',
height: '100%',
animation: isTransitioning ? `subtlePageFlash ${flashDuration}s ease-out` : undefined,
}}
>
<div
style={{
position: 'relative',
width: '100%',
maxWidth: '800px',
marginBottom: '12px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<div>
<div
style={{
width: '100%',
height: '8px',
background: '#e5e7eb',
borderRadius: '4px',
overflow: 'hidden',
marginBottom: '8px',
}}
>
<div
style={{
height: '100%',
background: 'linear-gradient(90deg, #28a745, #20c997)',
borderRadius: '4px',
width: `${progressPercentage}%`,
transition: 'width 0.5s ease',
}}
/>
</div>
<span
style={{
fontSize: '14px',
fontWeight: 'bold',
color: '#374151',
}}
>
Card {state.currentCardIndex + 1} of {state.quizCards.length}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
style={{
background: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '6px 12px',
fontSize: '12px',
cursor: 'pointer',
transition: 'background 0.2s ease',
}}
onClick={() => resetGame?.()}
>
End Quiz
</button>
</div>
</div>
{/* Persistent abacus container - stays mounted during entire memorize phase */}
<div
style={{
width: 'min(90vw, 800px)',
height: 'min(70vh, 500px)',
display: isDisplayPhaseActive ? 'flex' : 'none',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto',
transition: 'opacity 0.3s ease',
overflow: 'visible',
padding: '20px 12px',
}}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '20px',
}}
>
{/* Persistent abacus with smooth bead animations and dynamically calculated columns */}
<AbacusReact
value={currentCard?.number || 0}
columns={maxColumns}
beadShape={appConfig.beadShape}
colorScheme={appConfig.colorScheme}
hideInactiveBeads={appConfig.hideInactiveBeads}
scaleFactor={5.5}
interactive={false}
showNumbers={false}
animated={true}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,682 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { isPrefix } from '@/lib/memory-quiz-utils'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { CardGrid } from './CardGrid'
export function InputPhase() {
const { state, dispatch, acceptNumber, rejectNumber, setInput, showResults } = useMemoryQuiz()
const _containerRef = useRef<HTMLDivElement>(null)
const [displayFeedback, setDisplayFeedback] = useState<'neutral' | 'correct' | 'incorrect'>(
'neutral'
)
// Use keyboard state from parent state instead of local state
const { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard } = state
// Debug: Log state changes and detect what's causing re-renders
useEffect(() => {
console.log('🔍 Keyboard state changed:', {
hasPhysicalKeyboard,
testingMode,
showOnScreenKeyboard,
})
console.trace('🔍 State change trace:')
}, [hasPhysicalKeyboard, testingMode, showOnScreenKeyboard])
// Debug: Monitor for unexpected state resets
useEffect(() => {
if (showOnScreenKeyboard) {
const timer = setTimeout(() => {
if (!showOnScreenKeyboard) {
console.error('🚨 Keyboard was unexpectedly hidden!')
}
}, 1000)
return () => clearTimeout(timer)
}
}, [showOnScreenKeyboard])
// Detect physical keyboard availability (disabled when testing mode is active)
useEffect(() => {
// Skip keyboard detection entirely when testing mode is enabled
if (testingMode) {
console.log('🧪 Testing mode enabled - skipping keyboard detection')
return
}
let detectionTimer: NodeJS.Timeout | null = null
const detectKeyboard = () => {
// Method 1: Check if device supports keyboard via media queries
const hasKeyboardSupport =
window.matchMedia('(pointer: fine)').matches && window.matchMedia('(hover: hover)').matches
// Method 2: Check if device is likely touch-only
const isTouchDevice =
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
// Method 3: Check viewport characteristics for mobile devices
const isMobileViewport = window.innerWidth <= 768 && window.innerHeight <= 1024
// Combined heuristic: assume no physical keyboard if:
// - It's a touch device AND has mobile viewport AND lacks precise pointer
const likelyNoKeyboard = isTouchDevice && isMobileViewport && !hasKeyboardSupport
console.log('⌨️ Keyboard detection result:', !likelyNoKeyboard)
dispatch({
type: 'SET_PHYSICAL_KEYBOARD',
hasKeyboard: !likelyNoKeyboard,
})
}
// Test for actual keyboard input within 3 seconds
let keyboardDetected = false
const handleFirstKeyPress = (e: KeyboardEvent) => {
if (/^[0-9]$/.test(e.key)) {
console.log('⌨️ Physical keyboard detected via keypress')
keyboardDetected = true
dispatch({ type: 'SET_PHYSICAL_KEYBOARD', hasKeyboard: true })
document.removeEventListener('keypress', handleFirstKeyPress)
if (detectionTimer) clearTimeout(detectionTimer)
}
}
// Start detection
document.addEventListener('keypress', handleFirstKeyPress)
// Fallback to heuristic detection after 3 seconds
detectionTimer = setTimeout(() => {
if (!keyboardDetected) {
console.log('⌨️ Using fallback keyboard detection')
detectKeyboard()
}
document.removeEventListener('keypress', handleFirstKeyPress)
}, 3000)
// Initial heuristic detection (but don't commit to it yet)
const initialDetection = setTimeout(detectKeyboard, 100)
return () => {
document.removeEventListener('keypress', handleFirstKeyPress)
if (detectionTimer) clearTimeout(detectionTimer)
clearTimeout(initialDetection)
}
}, [testingMode, dispatch])
const acceptCorrectNumber = useCallback(
(number: number) => {
acceptNumber?.(number)
setInput?.('')
setDisplayFeedback('correct')
setTimeout(() => setDisplayFeedback('neutral'), 500)
// Auto-finish if all found
if (state.foundNumbers.length + 1 === state.correctAnswers.length) {
setTimeout(() => showResults?.(), 1000)
}
},
[acceptNumber, setInput, showResults, state.foundNumbers.length, state.correctAnswers.length]
)
const handleIncorrectGuess = useCallback(() => {
const wrongNumber = parseInt(state.currentInput, 10)
if (!Number.isNaN(wrongNumber)) {
dispatch({ type: 'ADD_WRONG_GUESS_ANIMATION', number: wrongNumber })
// Clear wrong guess animations after explosion
setTimeout(() => {
dispatch({ type: 'CLEAR_WRONG_GUESS_ANIMATIONS' })
}, 1500)
}
rejectNumber?.()
setInput?.('')
setDisplayFeedback('incorrect')
setTimeout(() => setDisplayFeedback('neutral'), 500)
// Auto-finish if out of guesses
if (state.guessesRemaining - 1 === 0) {
setTimeout(() => showResults?.(), 1000)
}
}, [dispatch, rejectNumber, setInput, showResults, state.guessesRemaining, state.currentInput])
// Simple keyboard event handlers that will be defined after callbacks
const handleKeyboardInput = useCallback(
(key: string) => {
// Handle number input
if (/^[0-9]$/.test(key)) {
// Only handle if input phase is active and guesses remain
if (state.guessesRemaining === 0) return
const newInput = state.currentInput + key
setInput?.(newInput)
// Clear any existing timeout
if (state.prefixAcceptanceTimeout) {
clearTimeout(state.prefixAcceptanceTimeout)
dispatch({ type: 'SET_PREFIX_TIMEOUT', timeout: null })
}
setDisplayFeedback('neutral')
const number = parseInt(newInput, 10)
if (Number.isNaN(number)) return
// Check if correct and not already found
if (state.correctAnswers.includes(number) && !state.foundNumbers.includes(number)) {
if (!isPrefix(newInput, state.correctAnswers, state.foundNumbers)) {
acceptCorrectNumber(number)
} else {
const timeout = setTimeout(() => {
acceptCorrectNumber(number)
}, 500)
dispatch({ type: 'SET_PREFIX_TIMEOUT', timeout })
}
} else {
// Check if this input could be a valid prefix or complete number
const couldBePrefix = state.correctAnswers.some((n) => n.toString().startsWith(newInput))
const isCompleteWrongNumber = !state.correctAnswers.includes(number) && !couldBePrefix
// Trigger explosion if:
// 1. It's a complete wrong number (length >= 2 or can't be a prefix)
// 2. It's a single digit that can't possibly be a prefix of any target
if ((newInput.length >= 2 || isCompleteWrongNumber) && state.guessesRemaining > 0) {
handleIncorrectGuess()
}
}
}
},
[
state.currentInput,
state.prefixAcceptanceTimeout,
state.correctAnswers,
state.foundNumbers,
state.guessesRemaining,
dispatch,
setInput,
acceptCorrectNumber,
handleIncorrectGuess,
]
)
const handleKeyboardBackspace = useCallback(() => {
if (state.currentInput.length > 0) {
const newInput = state.currentInput.slice(0, -1)
setInput?.(newInput)
// Clear any existing timeout
if (state.prefixAcceptanceTimeout) {
clearTimeout(state.prefixAcceptanceTimeout)
dispatch({ type: 'SET_PREFIX_TIMEOUT', timeout: null })
}
setDisplayFeedback('neutral')
}
}, [state.currentInput, state.prefixAcceptanceTimeout, dispatch, setInput])
// Set up global keyboard listeners
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle backspace/delete on keydown to prevent repetition
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault()
handleKeyboardBackspace()
}
}
const handleKeyPressEvent = (e: KeyboardEvent) => {
// Handle number input
if (/^[0-9]$/.test(e.key)) {
handleKeyboardInput(e.key)
}
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keypress', handleKeyPressEvent)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keypress', handleKeyPressEvent)
}
}, [handleKeyboardInput, handleKeyboardBackspace])
const hasFoundSome = state.foundNumbers.length > 0
const hasFoundAll = state.foundNumbers.length === state.correctAnswers.length
const outOfGuesses = state.guessesRemaining === 0
const showFinishButtons = hasFoundAll || outOfGuesses || hasFoundSome
return (
<div
style={{
textAlign: 'center',
padding: '12px',
paddingBottom:
(hasPhysicalKeyboard === false || testingMode) && state.guessesRemaining > 0
? '100px'
: '12px', // Add space for keyboard
maxWidth: '800px',
margin: '0 auto',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
}}
>
<h3
style={{
marginBottom: '16px',
color: '#1f2937',
fontSize: '18px',
fontWeight: '600',
}}
>
Enter the Numbers You Remember
</h3>
<div
style={{
display: 'flex',
justifyContent: 'center',
gap: '16px',
marginBottom: '20px',
padding: '16px',
background: '#f9fafb',
borderRadius: '8px',
flexWrap: 'wrap',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minWidth: '80px',
}}
>
<span
style={{
fontSize: '12px',
color: '#6b7280',
fontWeight: '500',
}}
>
Cards shown:
</span>
<span
style={{
fontSize: '20px',
fontWeight: 'bold',
color: '#1f2937',
}}
>
{state.quizCards.length}
</span>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minWidth: '80px',
}}
>
<span
style={{
fontSize: '12px',
color: '#6b7280',
fontWeight: '500',
}}
>
Guesses left:
</span>
<span
style={{
fontSize: '20px',
fontWeight: 'bold',
color: '#1f2937',
}}
>
{state.guessesRemaining}
</span>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minWidth: '80px',
}}
>
<span
style={{
fontSize: '12px',
color: '#6b7280',
fontWeight: '500',
}}
>
Found:
</span>
<span
style={{
fontSize: '20px',
fontWeight: 'bold',
color: '#1f2937',
}}
>
{state.foundNumbers.length}
</span>
</div>
</div>
<div
style={{
position: 'relative',
margin: '16px 0',
textAlign: 'center',
}}
>
<div
style={{
fontSize: '12px',
color: '#6b7280',
marginBottom: '8px',
fontWeight: '500',
}}
>
{state.guessesRemaining === 0
? '🚫 No more guesses available'
: '⌨️ Type the numbers you remember'}
</div>
{/* Testing control - remove in production */}
<div
style={{
fontSize: '10px',
color: '#9ca3af',
marginBottom: '4px',
}}
>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
justifyContent: 'center',
}}
>
<input
type="checkbox"
checked={testingMode}
onChange={(e) =>
dispatch({
type: 'SET_TESTING_MODE',
enabled: e.target.checked,
})
}
/>
Test on-screen keyboard (for demo)
</label>
<div style={{ fontSize: '9px', opacity: 0.7 }}>
Keyboard detected:{' '}
{hasPhysicalKeyboard === null ? 'detecting...' : hasPhysicalKeyboard ? 'yes' : 'no'}
</div>
</div>
<div
style={{
minHeight: '50px',
padding: '12px 16px',
fontSize: '22px',
fontFamily: 'system-ui, -apple-system, sans-serif',
textAlign: 'center',
fontWeight: '600',
color: state.guessesRemaining === 0 ? '#6b7280' : '#1f2937',
letterSpacing: '1px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.3s ease',
background:
displayFeedback === 'correct'
? 'linear-gradient(45deg, #d4edda, #c3e6cb)'
: displayFeedback === 'incorrect'
? 'linear-gradient(45deg, #f8d7da, #f1b0b7)'
: state.guessesRemaining === 0
? '#e5e7eb'
: 'linear-gradient(135deg, #f0f8ff, #e6f3ff)',
borderRadius: '12px',
position: 'relative',
border: '2px solid',
borderColor:
displayFeedback === 'correct'
? '#28a745'
: displayFeedback === 'incorrect'
? '#dc3545'
: state.guessesRemaining === 0
? '#9ca3af'
: '#3b82f6',
boxShadow:
displayFeedback === 'correct'
? '0 4px 12px rgba(40, 167, 69, 0.2)'
: displayFeedback === 'incorrect'
? '0 4px 12px rgba(220, 53, 69, 0.2)'
: '0 4px 12px rgba(59, 130, 246, 0.15)',
cursor: state.guessesRemaining === 0 ? 'not-allowed' : 'pointer',
}}
>
<span style={{ opacity: 1, position: 'relative' }}>
{state.guessesRemaining === 0
? '🔒 Game Over'
: state.currentInput || (
<span
style={{
color: '#74c0fc',
opacity: 0.8,
fontStyle: 'normal',
fontSize: '20px',
}}
>
💭 Think & Type
</span>
)}
{state.currentInput && (
<span
style={{
position: 'absolute',
right: '-8px',
top: '50%',
transform: 'translateY(-50%)',
width: '2px',
height: '20px',
background: '#3b82f6',
animation: 'blink 1s infinite',
}}
/>
)}
</span>
</div>
</div>
{/* Visual card grid showing cards the user was shown */}
<div
style={{
marginTop: '12px',
flex: 1,
overflow: 'auto',
minHeight: '0',
}}
>
<CardGrid state={state} />
</div>
{/* Wrong guess explosion animations */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
pointerEvents: 'none',
zIndex: 1000,
}}
>
{state.wrongGuessAnimations.map((animation) => (
<div
key={animation.id}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '48px',
fontWeight: 'bold',
color: '#ef4444',
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)',
animation: 'explode 1.5s ease-out forwards',
}}
>
{animation.number}
</div>
))}
</div>
{/* Simple fixed keyboard bar - appears when needed, no hiding of game elements */}
{(hasPhysicalKeyboard === false || testingMode) && state.guessesRemaining > 0 && (
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
borderTop: '2px solid #3b82f6',
padding: '12px',
zIndex: 1000,
display: 'flex',
gap: '8px',
justifyContent: 'center',
flexWrap: 'wrap',
boxShadow: '0 -4px 12px rgba(0, 0, 0, 0.1)',
}}
>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((digit) => (
<button
key={digit}
style={{
padding: '12px 16px',
border: '2px solid #e5e7eb',
borderRadius: '8px',
background: 'white',
fontSize: '18px',
fontWeight: 'bold',
color: '#1f2937',
cursor: 'pointer',
minWidth: '50px',
minHeight: '50px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
transition: 'all 0.15s ease',
}}
onMouseDown={(e) => {
e.currentTarget.style.transform = 'scale(0.95)'
e.currentTarget.style.background = '#f3f4f6'
e.currentTarget.style.borderColor = '#3b82f6'
}}
onMouseUp={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.background = 'white'
e.currentTarget.style.borderColor = '#e5e7eb'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.background = 'white'
e.currentTarget.style.borderColor = '#e5e7eb'
}}
onClick={() => handleKeyboardInput(digit.toString())}
>
{digit}
</button>
))}
<button
style={{
padding: '12px 16px',
border: '2px solid #dc2626',
borderRadius: '8px',
background: state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb',
fontSize: '14px',
fontWeight: 'bold',
color: state.currentInput.length > 0 ? '#dc2626' : '#9ca3af',
cursor: state.currentInput.length > 0 ? 'pointer' : 'not-allowed',
minWidth: '70px',
minHeight: '50px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
disabled={state.currentInput.length === 0}
onClick={handleKeyboardBackspace}
>
</button>
</div>
)}
{showFinishButtons && (
<div
style={{
display: 'flex',
justifyContent: 'center',
gap: '8px',
marginTop: '12px',
paddingTop: '12px',
borderTop: '1px solid #e5e7eb',
flexWrap: 'wrap',
}}
>
<button
style={{
padding: '10px 20px',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: '#3b82f6',
color: 'white',
minWidth: '120px',
}}
onClick={() => showResults?.()}
>
{hasFoundAll ? 'Finish Quiz' : 'Show Results'}
</button>
{hasFoundSome && !hasFoundAll && !outOfGuesses && (
<button
style={{
padding: '10px 20px',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: '#6b7280',
color: 'white',
minWidth: '120px',
}}
onClick={() => showResults?.()}
>
Can't Remember More
</button>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,157 @@
'use client'
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 { DisplayPhase } from './DisplayPhase'
import { InputPhase } from './InputPhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
// CSS animations that need to be global
const globalAnimations = `
@keyframes pulse {
0% { transform: scale(1); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
50% { transform: scale(1.05); box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5); }
100% { transform: scale(1); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
}
@keyframes subtlePageFlash {
0% { background: linear-gradient(to bottom right, #f0fdf4, #ecfdf5); }
50% { background: linear-gradient(to bottom right, #dcfce7, #d1fae5); }
100% { background: linear-gradient(to bottom right, #f0fdf4, #ecfdf5); }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
@keyframes explode {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
50% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.5);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(2) rotate(180deg);
}
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
`
export function MemoryQuizGame() {
const router = useRouter()
const { state, exitSession, resetGame } = useMemoryQuiz()
return (
<PageWithNav
navTitle="Memory Lightning"
navEmoji="🧠"
emphasizePlayerSelection={state.gamePhase === 'setup'}
onExitSession={() => {
exitSession?.()
router.push('/arcade')
}}
onNewGame={() => {
resetGame?.()
}}
>
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
padding: '20px 8px',
minHeight: '100vh',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
}}
>
<div
style={{
maxWidth: '100%',
margin: '0 auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<div
className={css({
textAlign: 'center',
mb: '4',
flexShrink: 0,
})}
>
<Link
href="/arcade"
className={css({
display: 'inline-flex',
alignItems: 'center',
color: 'gray.600',
textDecoration: 'none',
mb: '4',
_hover: { color: 'gray.800' },
})}
>
Back to Champion Arena
</Link>
</div>
<div
className={css({
bg: 'white',
rounded: 'xl',
shadow: 'xl',
overflow: 'hidden',
border: '1px solid',
borderColor: 'gray.200',
flex: 1,
display: 'flex',
flexDirection: 'column',
maxHeight: '100%',
})}
>
<div
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
})}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'display' && <DisplayPhase />}
{state.gamePhase === 'input' && <InputPhase key="input-phase" />}
{state.gamePhase === 'results' && <ResultsPhase />}
</div>
</div>
</div>
</div>
</PageWithNav>
)
}

View File

@@ -0,0 +1,219 @@
import { AbacusReact } from '@soroban/abacus-react'
import type { SorobanQuizState } from '../types'
interface ResultsCardGridProps {
state: SorobanQuizState
}
export function ResultsCardGrid({ state }: ResultsCardGridProps) {
if (state.quizCards.length === 0) return null
// Calculate optimal grid layout based on number of cards (same as CardGrid)
const cardCount = state.quizCards.length
// Define static grid classes that Panda can generate (same as CardGrid)
const getGridClass = (count: number) => {
if (count <= 2) return 'repeat(2, 1fr)'
if (count <= 4) return 'repeat(2, 1fr)'
if (count <= 6) return 'repeat(3, 1fr)'
if (count <= 9) return 'repeat(3, 1fr)'
if (count <= 12) return 'repeat(4, 1fr)'
return 'repeat(5, 1fr)'
}
const getCardSize = (count: number) => {
if (count <= 2) return { minSize: '180px', cardHeight: '160px' }
if (count <= 4) return { minSize: '160px', cardHeight: '150px' }
if (count <= 6) return { minSize: '140px', cardHeight: '140px' }
if (count <= 9) return { minSize: '120px', cardHeight: '130px' }
if (count <= 12) return { minSize: '110px', cardHeight: '120px' }
return { minSize: '100px', cardHeight: '110px' }
}
const gridClass = getGridClass(cardCount)
const cardSize = getCardSize(cardCount)
return (
<div>
<div
style={{
display: 'grid',
gap: '8px',
padding: '6px',
justifyContent: 'center',
maxWidth: '100%',
margin: '0 auto',
gridTemplateColumns: gridClass,
}}
>
{state.quizCards.map((card, index) => {
const isRevealed = true // All cards revealed in results
const wasFound = state.foundNumbers.includes(card.number)
return (
<div
key={`${card.number}-${index}`}
style={{
perspective: '1000px',
position: 'relative',
aspectRatio: '3/4',
height: cardSize.cardHeight,
minWidth: cardSize.minSize,
}}
>
<div
style={{
position: 'relative',
width: '100%',
height: '100%',
textAlign: 'center',
transition: 'transform 0.8s',
transformStyle: 'preserve-3d',
transform: isRevealed ? 'rotateY(180deg)' : 'rotateY(0deg)',
}}
>
{/* Card back (hidden state) - not visible in results */}
<div
style={{
position: 'absolute',
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)',
color: 'white',
fontSize: '24px',
fontWeight: 'bold',
textShadow: '1px 1px 2px rgba(0, 0, 0, 0.3)',
border: '2px solid #5f3dc4',
}}
>
<div style={{ opacity: 0.8 }}>?</div>
</div>
{/* Card front (revealed state) with success/failure indicators */}
<div
style={{
position: 'absolute',
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
background: 'white',
border: '2px solid',
borderColor: wasFound ? '#10b981' : '#ef4444',
transform: 'rotateY(180deg)',
}}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
padding: '4px',
}}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AbacusReact
value={card.number}
columns="auto"
beadShape="diamond"
colorScheme="place-value"
hideInactiveBeads={false}
scaleFactor={1.2}
interactive={false}
showNumbers={false}
animated={false}
/>
</div>
</div>
{/* Right/Wrong indicator overlay */}
<div
style={{
position: 'absolute',
top: '4px',
right: '4px',
width: '20px',
height: '20px',
borderRadius: '50%',
background: wasFound ? '#10b981' : '#ef4444',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 'bold',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.2)',
}}
>
{wasFound ? '✓' : '✗'}
</div>
{/* Number label overlay */}
<div
style={{
position: 'absolute',
bottom: '4px',
left: '4px',
padding: '2px 4px',
borderRadius: '3px',
background: 'rgba(0, 0, 0, 0.7)',
color: 'white',
fontSize: '10px',
fontWeight: 'bold',
}}
>
{card.number}
</div>
</div>
</div>
</div>
)
})}
</div>
{/* Summary row for large numbers of cards (same as CardGrid) */}
{cardCount > 8 && (
<div
style={{
marginTop: '8px',
padding: '6px 8px',
background: '#eff6ff',
borderRadius: '6px',
border: '1px solid #bfdbfe',
textAlign: 'center',
fontSize: '12px',
color: '#1d4ed8',
}}
>
<strong>{state.foundNumbers.length}</strong> of <strong>{cardCount}</strong> cards found
{state.foundNumbers.length > 0 && (
<span style={{ marginLeft: '6px', fontWeight: 'normal' }}>
({Math.round((state.foundNumbers.length / cardCount) * 100)}% complete)
</span>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,193 @@
import { useAbacusConfig } from '@soroban/abacus-react'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { DIFFICULTY_LEVELS, type DifficultyLevel, type QuizCard } from '../types'
import { ResultsCardGrid } from './ResultsCardGrid'
// Generate quiz cards with difficulty-based number ranges
const generateQuizCards = (
count: number,
difficulty: DifficultyLevel,
appConfig: any
): QuizCard[] => {
const { min, max } = DIFFICULTY_LEVELS[difficulty].range
// Generate unique numbers - no duplicates allowed
const numbers: number[] = []
const maxAttempts = (max - min + 1) * 10 // Prevent infinite loops
let attempts = 0
while (numbers.length < count && attempts < maxAttempts) {
const newNumber = Math.floor(Math.random() * (max - min + 1)) + min
if (!numbers.includes(newNumber)) {
numbers.push(newNumber)
}
attempts++
}
// If we couldn't generate enough unique numbers, fill with sequential numbers
if (numbers.length < count) {
for (let i = min; i <= max && numbers.length < count; i++) {
if (!numbers.includes(i)) {
numbers.push(i)
}
}
}
return numbers.map((number) => ({
number,
svgComponent: <div />, // Placeholder - not used in results phase
element: null,
}))
}
export function ResultsPhase() {
const { state, resetGame, startQuiz } = useMemoryQuiz()
const appConfig = useAbacusConfig()
const correct = state.foundNumbers.length
const total = state.correctAnswers.length
const percentage = Math.round((correct / total) * 100)
return (
<div
style={{
textAlign: 'center',
padding: '12px',
maxWidth: '800px',
margin: '0 auto',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
}}
>
<h3
style={{
marginBottom: '20px',
color: '#1f2937',
fontSize: '18px',
fontWeight: '600',
}}
>
Quiz Results
</h3>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '16px',
marginBottom: '20px',
padding: '16px',
background: '#f9fafb',
borderRadius: '8px',
flexWrap: 'wrap',
}}
>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '50%',
background: 'linear-gradient(45deg, #3b82f6, #2563eb)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '18px',
fontWeight: 'bold',
}}
>
<span>{percentage}%</span>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
gap: '12px',
fontSize: '16px',
}}
>
<span style={{ fontWeight: '500', color: '#6b7280' }}>Correct:</span>
<span style={{ fontWeight: 'bold' }}>{correct}</span>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
gap: '12px',
fontSize: '16px',
}}
>
<span style={{ fontWeight: '500', color: '#6b7280' }}>Total:</span>
<span style={{ fontWeight: 'bold' }}>{total}</span>
</div>
</div>
</div>
{/* Results card grid - reuse CardGrid but with all cards revealed and status indicators */}
<div style={{ marginTop: '12px', flex: 1, overflow: 'auto' }}>
<ResultsCardGrid state={state} />
</div>
<div
style={{
display: 'flex',
justifyContent: 'center',
gap: '8px',
marginTop: '16px',
flexWrap: 'wrap',
}}
>
<button
style={{
padding: '10px 20px',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: '#10b981',
color: 'white',
minWidth: '120px',
}}
onClick={() => {
resetGame?.()
const quizCards = generateQuizCards(
state.selectedCount,
state.selectedDifficulty,
appConfig
)
startQuiz?.(quizCards)
}}
>
Try Again
</button>
<button
style={{
padding: '10px 20px',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: '#6b7280',
color: 'white',
minWidth: '120px',
}}
onClick={() => resetGame?.()}
>
Back to Cards
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,262 @@
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { DIFFICULTY_LEVELS, type DifficultyLevel, type QuizCard } from '../types'
// Generate quiz cards with difficulty-based number ranges
const generateQuizCards = (
count: number,
difficulty: DifficultyLevel,
appConfig: any
): QuizCard[] => {
const { min, max } = DIFFICULTY_LEVELS[difficulty].range
// Generate unique numbers - no duplicates allowed
const numbers: number[] = []
const maxAttempts = (max - min + 1) * 10 // Prevent infinite loops
let attempts = 0
while (numbers.length < count && attempts < maxAttempts) {
const newNumber = Math.floor(Math.random() * (max - min + 1)) + min
if (!numbers.includes(newNumber)) {
numbers.push(newNumber)
}
attempts++
}
// If we couldn't generate enough unique numbers, fill with sequential numbers
if (numbers.length < count) {
for (let i = min; i <= max && numbers.length < count; i++) {
if (!numbers.includes(i)) {
numbers.push(i)
}
}
}
return numbers.map((number) => ({
number,
svgComponent: (
<AbacusReact
value={number}
columns="auto"
beadShape={appConfig.beadShape}
colorScheme={appConfig.colorScheme}
hideInactiveBeads={appConfig.hideInactiveBeads}
scaleFactor={1.0}
interactive={false}
showNumbers={false}
animated={false}
soundEnabled={appConfig.soundEnabled}
soundVolume={appConfig.soundVolume}
/>
),
element: null,
}))
}
export function SetupPhase() {
const { state, setConfig, startQuiz } = useMemoryQuiz()
const appConfig = useAbacusConfig()
const handleCountSelect = (count: number) => {
setConfig?.('selectedCount', count)
}
const handleTimeChange = (time: number) => {
setConfig?.('displayTime', time)
}
const handleDifficultySelect = (difficulty: DifficultyLevel) => {
setConfig?.('selectedDifficulty', difficulty)
}
const handleStartQuiz = () => {
const quizCards = generateQuizCards(
state.selectedCount ?? 5,
state.selectedDifficulty ?? 'easy',
appConfig
)
startQuiz?.(quizCards)
}
return (
<div
style={{
textAlign: 'center',
padding: '12px',
maxWidth: '100%',
margin: '0 auto',
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div
style={{
maxWidth: '100%',
margin: '0 auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '16px',
overflow: 'auto',
}}
>
<div style={{ margin: '12px 0' }}>
<label
style={{
display: 'block',
fontWeight: 'bold',
marginBottom: '8px',
color: '#6b7280',
fontSize: '14px',
}}
>
Difficulty Level:
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '8px',
justifyContent: 'center',
}}
>
{Object.entries(DIFFICULTY_LEVELS).map(([key, level]) => (
<button
key={key}
type="button"
style={{
background: state.selectedDifficulty === key ? '#3b82f6' : 'white',
color: state.selectedDifficulty === key ? 'white' : '#1f2937',
border: '2px solid',
borderColor: state.selectedDifficulty === key ? '#3b82f6' : '#d1d5db',
borderRadius: '8px',
padding: '8px 12px',
cursor: 'pointer',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
gap: '2px',
fontSize: '12px',
}}
onClick={() => handleDifficultySelect(key as DifficultyLevel)}
title={level.description}
>
<div style={{ fontWeight: 'bold', fontSize: '13px' }}>{level.name}</div>
<div style={{ fontSize: '10px', opacity: 0.8 }}>{level.description}</div>
</button>
))}
</div>
</div>
<div style={{ margin: '12px 0' }}>
<label
style={{
display: 'block',
fontWeight: 'bold',
marginBottom: '8px',
color: '#6b7280',
fontSize: '14px',
}}
>
Cards to Quiz:
</label>
<div
style={{
display: 'flex',
gap: '6px',
justifyContent: 'center',
flexWrap: 'wrap',
}}
>
{[2, 5, 8, 12, 15].map((count) => (
<button
key={count}
type="button"
style={{
background: state.selectedCount === count ? '#3b82f6' : 'white',
color: state.selectedCount === count ? 'white' : '#1f2937',
border: '2px solid',
borderColor: state.selectedCount === count ? '#3b82f6' : '#d1d5db',
borderRadius: '8px',
padding: '8px 16px',
cursor: 'pointer',
fontSize: '14px',
minWidth: '50px',
}}
onClick={() => handleCountSelect(count)}
>
{count}
</button>
))}
</div>
</div>
<div style={{ margin: '12px 0' }}>
<label
style={{
display: 'block',
fontWeight: 'bold',
marginBottom: '8px',
color: '#6b7280',
fontSize: '14px',
}}
>
Display Time per Card:
</label>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
}}
>
<input
type="range"
min="0.5"
max="10"
step="0.5"
value={state.displayTime ?? 2.0}
onChange={(e) => handleTimeChange(parseFloat(e.target.value))}
style={{
flex: 1,
maxWidth: '200px',
}}
/>
<span
style={{
fontWeight: 'bold',
color: '#3b82f6',
minWidth: '40px',
fontSize: '14px',
}}
>
{(state.displayTime ?? 2.0).toFixed(1)}s
</span>
</div>
</div>
<button
style={{
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '12px 24px',
fontSize: '16px',
fontWeight: 'bold',
cursor: 'pointer',
marginTop: '16px',
width: '100%',
maxWidth: '200px',
}}
onClick={handleStartQuiz}
>
Start Quiz
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,113 @@
'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

@@ -0,0 +1,41 @@
'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
// 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', 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

@@ -0,0 +1,286 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect } from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import type { GameMove } from '@/lib/arcade/validation'
import { initialState } from '../reducer'
import type { QuizCard, SorobanQuizState } from '../types'
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
/**
* 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) {
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
let quizCards: QuizCard[]
let correctAnswers: number[]
if (clientQuizCards) {
// Client-side optimistic update: use the full quizCards with React components
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)
quizCards = serverNumbers.map((number: number) => ({
number,
svgComponent: null,
element: null,
}))
correctAnswers = serverNumbers
} else {
// Fallback: preserve existing state
quizCards = state.quizCards
correctAnswers = state.correctAnswers
}
const cardCount = quizCards.length
return {
...state,
quizCards,
correctAnswers,
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: cardCount + Math.floor(cardCount / 2),
gamePhase: 'display',
incorrectGuesses: 0,
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
}
}
case 'NEXT_CARD':
return {
...state,
currentCardIndex: state.currentCardIndex + 1,
}
case 'SHOW_INPUT_PHASE':
return {
...state,
gamePhase: 'input',
}
case 'ACCEPT_NUMBER':
return {
...state,
foundNumbers: [...state.foundNumbers, move.data.number],
currentInput: '',
}
case 'REJECT_NUMBER':
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
}
case 'SET_INPUT':
return {
...state,
currentInput: move.data.input,
}
case 'SHOW_RESULTS':
return {
...state,
gamePhase: 'results',
}
case 'RESET_QUIZ':
return {
...state,
gamePhase: 'setup',
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
}
case 'SET_CONFIG': {
const { field, value } = move.data as {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty'
value: any
}
return {
...state,
[field]: value,
}
}
default:
return state
}
}
/**
* RoomMemoryQuizProvider - Provides context for room-based multiplayer mode
*
* 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 }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
// Arcade session integration WITH room sync
const {
state,
sendMove,
connected: _connected,
exitSession,
} = useArcadeSession<SorobanQuizState>({
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
initialState,
applyMove: applyMoveOptimistically,
})
// 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 - send moves to arcade session
// For single-player quiz, we use viewerId as playerId
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)
sendMove({
type: 'START_QUIZ',
playerId: viewerId || '',
data: {
numbers, // Send to server
quizCards, // Keep for optimistic local update
},
})
},
[viewerId, sendMove]
)
const nextCard = useCallback(() => {
sendMove({
type: 'NEXT_CARD',
playerId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const showInputPhase = useCallback(() => {
sendMove({
type: 'SHOW_INPUT_PHASE',
playerId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const acceptNumber = useCallback(
(number: number) => {
sendMove({
type: 'ACCEPT_NUMBER',
playerId: viewerId || '',
data: { number },
})
},
[viewerId, sendMove]
)
const rejectNumber = useCallback(() => {
sendMove({
type: 'REJECT_NUMBER',
playerId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const setInput = useCallback(
(input: string) => {
sendMove({
type: 'SET_INPUT',
playerId: viewerId || '',
data: { input },
})
},
[viewerId, sendMove]
)
const showResults = useCallback(() => {
sendMove({
type: 'SHOW_RESULTS',
playerId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const resetGame = useCallback(() => {
sendMove({
type: 'RESET_QUIZ',
playerId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const setConfig = useCallback(
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => {
sendMove({
type: 'SET_CONFIG',
playerId: viewerId || '',
data: { field, value },
})
},
[viewerId, sendMove]
)
const contextValue: MemoryQuizContextValue = {
state,
dispatch: () => {
// No-op - replaced with action creators
console.warn('dispatch() is deprecated in room 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>
}
// Export the hook for this provider
export { useMemoryQuiz } from './MemoryQuizContext'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,106 @@
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,
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 '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':
return {
...state,
foundNumbers: [...state.foundNumbers, action.number],
currentInput: '',
}
case 'REJECT_NUMBER':
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
}
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,
// 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

@@ -0,0 +1,90 @@
export interface QuizCard {
number: number
svgComponent: JSX.Element
element: HTMLElement | null
}
export interface SorobanQuizState {
// Core game data
cards: QuizCard[]
quizCards: QuizCard[]
correctAnswers: number[]
// Game progression
currentCardIndex: number
displayTime: number
selectedCount: number
selectedDifficulty: DifficultyLevel
// Input system state
foundNumbers: number[]
guessesRemaining: number
currentInput: string
incorrectGuesses: number
// UI state
gamePhase: 'setup' | 'display' | 'input' | 'results'
prefixAcceptanceTimeout: NodeJS.Timeout | null
finishButtonsBound: boolean
wrongGuessAnimations: Array<{
number: number
id: string
timestamp: number
}>
// Keyboard state (moved from InputPhase to persist across re-renders)
hasPhysicalKeyboard: boolean | null
testingMode: boolean
showOnScreenKeyboard: boolean
}
export type QuizAction =
| { type: 'SET_CARDS'; cards: QuizCard[] }
| { type: 'SET_DISPLAY_TIME'; time: number }
| { type: 'SET_SELECTED_COUNT'; count: number }
| { type: 'SET_DIFFICULTY'; difficulty: DifficultyLevel }
| { type: 'START_QUIZ'; quizCards: QuizCard[] }
| { type: 'NEXT_CARD' }
| { type: 'SHOW_INPUT_PHASE' }
| { type: 'ACCEPT_NUMBER'; number: number }
| { type: 'REJECT_NUMBER' }
| { type: 'ADD_WRONG_GUESS_ANIMATION'; number: number }
| { type: 'CLEAR_WRONG_GUESS_ANIMATIONS' }
| { type: 'SET_INPUT'; input: string }
| { type: 'SET_PREFIX_TIMEOUT'; timeout: NodeJS.Timeout | null }
| { type: 'SHOW_RESULTS' }
| { type: 'RESET_QUIZ' }
| { type: 'SET_PHYSICAL_KEYBOARD'; hasKeyboard: boolean | null }
| { type: 'SET_TESTING_MODE'; enabled: boolean }
| { type: 'TOGGLE_ONSCREEN_KEYBOARD' }
// Difficulty levels with progressive number ranges
export const DIFFICULTY_LEVELS = {
beginner: {
name: 'Beginner',
range: { min: 1, max: 9 },
description: 'Single digits (1-9)',
},
easy: {
name: 'Easy',
range: { min: 10, max: 99 },
description: 'Two digits (10-99)',
},
medium: {
name: 'Medium',
range: { min: 100, max: 499 },
description: 'Three digits (100-499)',
},
hard: {
name: 'Hard',
range: { min: 500, max: 999 },
description: 'Large numbers (500-999)',
},
expert: {
name: 'Expert',
range: { min: 1, max: 999 },
description: 'Mixed range (1-999)',
},
} as const
export type DifficultyLevel = keyof typeof DIFFICULTY_LEVELS

View File

@@ -4,6 +4,8 @@ import { useRouter } from 'next/navigation'
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
import { MemoryQuizGame } from '../memory-quiz/components/MemoryQuizGame'
import { RoomMemoryQuizProvider } from '../memory-quiz/context/RoomMemoryQuizProvider'
import { GAMES_CONFIG } from '@/components/GameSelector'
import type { GameType } from '@/components/GameSelector'
import { PageWithNav } from '@/components/PageWithNav'
@@ -205,7 +207,14 @@ export default function RoomPage() {
</RoomMemoryPairsProvider>
)
// TODO: Add other games (complement-race, memory-quiz, etc.)
case 'memory-quiz':
return (
<RoomMemoryQuizProvider>
<MemoryQuizGame />
</RoomMemoryQuizProvider>
)
// TODO: Add other games (complement-race, etc.)
default:
return (
<PageWithNav

View File

@@ -21,7 +21,7 @@ function GamesPageContent() {
const _handleGameClick = (gameType: string) => {
// Navigate directly to games using the centralized game mode with Next.js router
console.log('🔄 GamesPage: Navigating with Next.js router (no page reload)')
if (gameType === 'memory-lightning') {
if (gameType === 'memory-quiz') {
router.push('/games/memory-quiz')
} else if (gameType === 'battle-arena') {
router.push('/games/matching')

View File

@@ -6,7 +6,7 @@ import { GameCard } from './GameCard'
// Game configuration defining player limits
export const GAMES_CONFIG = {
'memory-lightning': {
'memory-quiz': {
name: 'Memory Lightning',
fullName: 'Memory Lightning ⚡',
maxPlayers: 1,

View File

@@ -836,7 +836,10 @@ export function ModerationNotifications({
router.push('/arcade/room')
} catch (error) {
console.error('Failed to join room:', error)
showError('Failed to join room', error instanceof Error ? error.message : undefined)
showError(
'Failed to join room',
error instanceof Error ? error.message : undefined
)
setIsAcceptingInvitation(false)
}
}}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import { useState } from 'react'
import { PlayerTooltip } from './PlayerTooltip'
import { ReportPlayerModal } from './ReportPlayerModal'

View File

@@ -67,6 +67,7 @@ describe('Arcade Session Integration', () => {
moves: 0,
scores: {},
activePlayers: ['1'],
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
@@ -76,6 +77,7 @@ describe('Arcade Session Integration', () => {
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
playerHovers: {},
}
const session = await createArcadeSession({
@@ -170,6 +172,7 @@ describe('Arcade Session Integration', () => {
moves: 0,
scores: { 1: 0 },
activePlayers: ['1'],
playerMetadata: {},
consecutiveMatches: { 1: 0 },
gameStartTime: Date.now(),
gameEndTime: null,
@@ -179,6 +182,7 @@ describe('Arcade Session Integration', () => {
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
playerHovers: {},
}
await createArcadeSession({

View File

@@ -52,38 +52,12 @@ describe('Orphaned Session Cleanup', () => {
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
it('should return undefined when session has no roomId', async () => {
// Create a session with a valid room
const session = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: testRoomId,
})
expect(session).toBeDefined()
expect(session.roomId).toBe(testRoomId)
// Manually set roomId to null to simulate orphaned session
await db
.update(schema.arcadeSessions)
.set({ roomId: null })
.where(eq(schema.arcadeSessions.userId, testUserId))
// Getting the session should auto-delete it and return undefined
const result = await getArcadeSession(testGuestId)
expect(result).toBeUndefined()
// Verify session was actually deleted
const [directCheck] = await db
.select()
.from(schema.arcadeSessions)
.where(eq(schema.arcadeSessions.userId, testUserId))
.limit(1)
expect(directCheck).toBeUndefined()
// NOTE: This test is no longer valid with roomId as primary key
// roomId cannot be null since it's the primary key with a foreign key constraint
// Orphaned sessions are now automatically cleaned up via CASCADE delete when room is deleted
it.skip('should return undefined when session has no roomId', async () => {
// This test scenario is impossible with the new schema where roomId is the primary key
// and has a foreign key constraint with CASCADE delete
})
it('should return undefined when session room has been deleted', async () => {

View File

@@ -5,13 +5,7 @@
import { and, desc, eq } from 'drizzle-orm'
import { db } from '@/db'
import {
roomBans,
roomMembers,
roomReports,
type NewRoomBan,
type NewRoomReport,
} from '@/db/schema'
import { roomBans, roomMembers, roomReports } from '@/db/schema'
import { recordRoomMemberHistory } from './room-member-history'
/**

View File

@@ -220,8 +220,10 @@ export async function applyGameMove(
const validator = getValidator(session.currentGame as GameName)
console.log('[SessionManager] About to validate move:', {
gameName: session.currentGame,
moveType: move.type,
playerId: move.playerId,
moveData: move.type === 'SET_CONFIG' ? (move as any).data : undefined,
gameStateCurrentPlayer: (session.gameState as any)?.currentPlayer,
gameStateActivePlayers: (session.gameState as any)?.activePlayers,
gameStatePhase: (session.gameState as any)?.gamePhase,

View File

@@ -0,0 +1,370 @@
/**
* Server-side validator for memory-quiz game
* Validates all game moves and state transitions
*/
import type { DifficultyLevel, SorobanQuizState } from '@/app/arcade/memory-quiz/types'
import type {
GameValidator,
MemoryQuizGameMove,
MemoryQuizSetConfigMove,
ValidationResult,
} from './types'
export class MemoryQuizGameValidator
implements GameValidator<SorobanQuizState, MemoryQuizGameMove>
{
validateMove(
state: SorobanQuizState,
move: MemoryQuizGameMove,
context?: { userId?: string; playerOwnership?: Record<string, string> }
): ValidationResult {
switch (move.type) {
case 'START_QUIZ':
return this.validateStartQuiz(state, move.data)
case 'NEXT_CARD':
return this.validateNextCard(state)
case 'SHOW_INPUT_PHASE':
return this.validateShowInputPhase(state)
case 'ACCEPT_NUMBER':
return this.validateAcceptNumber(state, move.data.number)
case 'REJECT_NUMBER':
return this.validateRejectNumber(state)
case 'SET_INPUT':
return this.validateSetInput(state, move.data.input)
case 'SHOW_RESULTS':
return this.validateShowResults(state)
case 'RESET_QUIZ':
return this.validateResetQuiz(state)
case 'SET_CONFIG': {
const configMove = move as MemoryQuizSetConfigMove
return this.validateSetConfig(state, configMove.data.field, configMove.data.value)
}
default:
return {
valid: false,
error: `Unknown move type: ${(move as any).type}`,
}
}
}
private validateStartQuiz(state: SorobanQuizState, data: any): ValidationResult {
// Can start quiz from setup or results phase
if (state.gamePhase !== 'setup' && state.gamePhase !== 'results') {
return {
valid: false,
error: 'Can only start quiz from setup or results phase',
}
}
// Accept either numbers array (from network) or quizCards (from client)
const numbers = data.numbers || data.quizCards?.map((c: any) => c.number)
if (!numbers || numbers.length === 0) {
return {
valid: false,
error: 'Quiz numbers are required',
}
}
// Create minimal quiz cards from numbers (server-side doesn't need React components)
const quizCards = numbers.map((number: number) => ({
number,
svgComponent: null, // Not needed server-side
element: null,
}))
const newState: SorobanQuizState = {
...state,
quizCards,
correctAnswers: numbers,
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: numbers.length + Math.floor(numbers.length / 2),
gamePhase: 'display',
incorrectGuesses: 0,
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
}
return {
valid: true,
newState,
}
}
private validateNextCard(state: SorobanQuizState): ValidationResult {
// Must be in display phase
if (state.gamePhase !== 'display') {
return {
valid: false,
error: 'NEXT_CARD only valid in display phase',
}
}
const newState: SorobanQuizState = {
...state,
currentCardIndex: state.currentCardIndex + 1,
}
return {
valid: true,
newState,
}
}
private validateShowInputPhase(state: SorobanQuizState): ValidationResult {
// Must have shown all cards
if (state.currentCardIndex < state.quizCards.length) {
return {
valid: false,
error: 'All cards must be shown before input phase',
}
}
const newState: SorobanQuizState = {
...state,
gamePhase: 'input',
}
return {
valid: true,
newState,
}
}
private validateAcceptNumber(state: SorobanQuizState, number: number): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
valid: false,
error: 'ACCEPT_NUMBER only valid in input phase',
}
}
// Number must be in correct answers
console.log('[MemoryQuizValidator] Checking number:', {
number,
correctAnswers: state.correctAnswers,
includes: state.correctAnswers.includes(number),
})
if (!state.correctAnswers.includes(number)) {
return {
valid: false,
error: 'Number is not a correct answer',
}
}
// Number must not be already found
if (state.foundNumbers.includes(number)) {
return {
valid: false,
error: 'Number already found',
}
}
const newState: SorobanQuizState = {
...state,
foundNumbers: [...state.foundNumbers, number],
currentInput: '',
}
return {
valid: true,
newState,
}
}
private validateRejectNumber(state: SorobanQuizState): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
valid: false,
error: 'REJECT_NUMBER only valid in input phase',
}
}
// Must have guesses remaining
if (state.guessesRemaining <= 0) {
return {
valid: false,
error: 'No guesses remaining',
}
}
const newState: SorobanQuizState = {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
}
return {
valid: true,
newState,
}
}
private validateSetInput(state: SorobanQuizState, input: string): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
valid: false,
error: 'SET_INPUT only valid in input phase',
}
}
// Input must be numeric
if (input && !/^\d+$/.test(input)) {
return {
valid: false,
error: 'Input must be numeric',
}
}
const newState: SorobanQuizState = {
...state,
currentInput: input,
}
return {
valid: true,
newState,
}
}
private validateShowResults(state: SorobanQuizState): ValidationResult {
// Can show results from input phase
if (state.gamePhase !== 'input') {
return {
valid: false,
error: 'SHOW_RESULTS only valid from input phase',
}
}
const newState: SorobanQuizState = {
...state,
gamePhase: 'results',
}
return {
valid: true,
newState,
}
}
private validateResetQuiz(state: SorobanQuizState): ValidationResult {
// Can reset from any phase
const newState: SorobanQuizState = {
...state,
gamePhase: 'setup',
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
}
return {
valid: true,
newState,
}
}
private validateSetConfig(
state: SorobanQuizState,
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty',
value: any
): ValidationResult {
// Can only change config during setup phase
if (state.gamePhase !== 'setup') {
return {
valid: false,
error: 'Cannot change configuration outside of setup phase',
}
}
// Validate field-specific values
switch (field) {
case 'selectedCount':
if (![2, 5, 8, 12, 15].includes(value)) {
return { valid: false, error: `Invalid selectedCount: ${value}` }
}
break
case 'displayTime':
if (typeof value !== 'number' || value < 0.5 || value > 10) {
return { valid: false, error: `Invalid displayTime: ${value}` }
}
break
case 'selectedDifficulty':
if (!['beginner', 'easy', 'medium', 'hard', 'expert'].includes(value)) {
return { valid: false, error: `Invalid selectedDifficulty: ${value}` }
}
break
default:
return { valid: false, error: `Unknown config field: ${field}` }
}
// Apply the configuration change
return {
valid: true,
newState: {
...state,
[field]: value,
},
}
}
isGameComplete(state: SorobanQuizState): boolean {
return state.gamePhase === 'results'
}
getInitialState(config: {
selectedCount: number
displayTime: number
selectedDifficulty: DifficultyLevel
}): SorobanQuizState {
return {
cards: [],
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
displayTime: config.displayTime,
selectedCount: config.selectedCount,
selectedDifficulty: config.selectedDifficulty,
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
wrongGuessAnimations: [],
hasPhysicalKeyboard: null,
testingMode: false,
showOnScreenKeyboard: false,
}
}
}
// Singleton instance
export const memoryQuizGameValidator = new MemoryQuizGameValidator()

View File

@@ -4,10 +4,12 @@
*/
import { matchingGameValidator } from './MatchingGameValidator'
import { memoryQuizGameValidator } from './MemoryQuizGameValidator'
import type { GameName, GameValidator } from './types'
const validators = new Map<GameName, GameValidator>([
['matching', matchingGameValidator],
['memory-quiz', memoryQuizGameValidator],
// Add other game validators here as they're implemented
])
@@ -20,4 +22,5 @@ export function getValidator(gameName: GameName): GameValidator {
}
export { matchingGameValidator } from './MatchingGameValidator'
export { memoryQuizGameValidator } from './MemoryQuizGameValidator'
export * from './types'

View File

@@ -4,6 +4,7 @@
*/
import type { MemoryPairsState } from '@/app/games/matching/context/types'
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
export type GameName = 'matching' | 'memory-quiz' | 'complement-race'
@@ -77,8 +78,74 @@ export type MatchingGameMove =
| MatchingResumeGameMove
| MatchingHoverCardMove
// Memory Quiz game specific moves
export interface MemoryQuizStartQuizMove extends GameMove {
type: 'START_QUIZ'
data: {
quizCards: any[] // QuizCard type from memory-quiz types
}
}
export interface MemoryQuizNextCardMove extends GameMove {
type: 'NEXT_CARD'
data: Record<string, never>
}
export interface MemoryQuizShowInputPhaseMove extends GameMove {
type: 'SHOW_INPUT_PHASE'
data: Record<string, never>
}
export interface MemoryQuizAcceptNumberMove extends GameMove {
type: 'ACCEPT_NUMBER'
data: {
number: number
}
}
export interface MemoryQuizRejectNumberMove extends GameMove {
type: 'REJECT_NUMBER'
data: Record<string, never>
}
export interface MemoryQuizSetInputMove extends GameMove {
type: 'SET_INPUT'
data: {
input: string
}
}
export interface MemoryQuizShowResultsMove extends GameMove {
type: 'SHOW_RESULTS'
data: Record<string, never>
}
export interface MemoryQuizResetQuizMove extends GameMove {
type: 'RESET_QUIZ'
data: Record<string, never>
}
export interface MemoryQuizSetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty'
value: any
}
}
export type MemoryQuizGameMove =
| MemoryQuizStartQuizMove
| MemoryQuizNextCardMove
| MemoryQuizShowInputPhaseMove
| MemoryQuizAcceptNumberMove
| MemoryQuizRejectNumberMove
| MemoryQuizSetInputMove
| MemoryQuizShowResultsMove
| MemoryQuizResetQuizMove
| MemoryQuizSetConfigMove
// Generic game state union
export type GameState = MemoryPairsState // Add other game states as union later
export type GameState = MemoryPairsState | SorobanQuizState // Add other game states as union later
/**
* Validation context for authorization checks

View File

@@ -14,7 +14,7 @@ import { createRoom, getRoomById } from './lib/arcade/room-manager'
import { getRoomMembers, getUserRooms, setMemberOnline } from './lib/arcade/room-membership'
import { getRoomActivePlayers, getRoomPlayerIds } from './lib/arcade/player-manager'
import type { GameMove, GameName } from './lib/arcade/validation'
import { matchingGameValidator } from './lib/arcade/validation/MatchingGameValidator'
import { getValidator } from './lib/arcade/validation'
// Use globalThis to store socket.io instance to avoid module isolation issues
// This ensures the same instance is accessible across dynamic imports
@@ -76,12 +76,29 @@ export function initializeSocketServer(httpServer: HTTPServer) {
const roomPlayerIds = await getRoomPlayerIds(roomId)
console.log('[join-arcade-session] Room active players:', roomPlayerIds)
// Get initial state from validator (starts in "setup" phase)
const initialState = matchingGameValidator.getInitialState({
difficulty: (room.gameConfig as any)?.difficulty || 6,
gameType: (room.gameConfig as any)?.gameType || 'abacus-numeral',
turnTimer: (room.gameConfig as any)?.turnTimer || 30,
})
// Get initial state from the correct validator based on game type
console.log('[join-arcade-session] Room game name:', room.gameName)
const validator = getValidator(room.gameName as GameName)
console.log('[join-arcade-session] Got validator for:', room.gameName)
// Different games have different initial configs
let initialState: any
if (room.gameName === 'matching') {
initialState = validator.getInitialState({
difficulty: (room.gameConfig as any)?.difficulty || 6,
gameType: (room.gameConfig as any)?.gameType || 'abacus-numeral',
turnTimer: (room.gameConfig as any)?.turnTimer || 30,
})
} else if (room.gameName === 'memory-quiz') {
initialState = validator.getInitialState({
selectedCount: (room.gameConfig as any)?.selectedCount || 5,
displayTime: (room.gameConfig as any)?.displayTime || 2.0,
selectedDifficulty: (room.gameConfig as any)?.selectedDifficulty || 'easy',
})
} else {
// Fallback for other games
initialState = validator.getInitialState(room.gameConfig || {})
}
session = await createArcadeSession({
userId,
@@ -162,8 +179,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
return
}
// Get initial state from validator
const initialState = matchingGameValidator.getInitialState({
// Get initial state from validator (this code path is matching-game specific)
const matchingValidator = getValidator('matching')
const initialState = matchingValidator.getInitialState({
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "3.14.0",
"version": "3.14.1",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [