Compare commits

...

4 Commits

Author SHA1 Message Date
semantic-release-bot
0fd680396c chore(release): 3.14.2 [skip ci]
## [3.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.1...v3.14.2) (2025-10-15)

### Bug Fixes

* **room:** update GAME_TYPE_TO_NAME mapping for memory-quiz ([4afa171](4afa171af2))
2025-10-15 00:06:54 +00:00
Thomas Hallock
4afa171af2 fix(room): update GAME_TYPE_TO_NAME mapping for memory-quiz
The GAMES_CONFIG was changed from 'memory-lightning' to 'memory-quiz'
but the GAME_TYPE_TO_NAME mapping in room/page.tsx still used the old key.

This caused the handleGameSelect function to fail silently when users
clicked on Memory Lightning in the "Change Game" screen, as it couldn't
find the mapping for 'memory-quiz'.

Also added debug logging to GameCard component to help diagnose issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 19:06:00 -05:00
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
34 changed files with 3206 additions and 1998 deletions

View File

@@ -1,3 +1,17 @@
## [3.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.1...v3.14.2) (2025-10-15)
### Bug Fixes
* **room:** update GAME_TYPE_TO_NAME mapping for memory-quiz ([4afa171](https://github.com/antialias/soroban-abacus-flashcards/commit/4afa171af212902120599b3d68f58cfbdf7820b0))
## [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,16 @@
"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:*)",
"Bash(echo:*)"
],
"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'
@@ -12,7 +14,7 @@ import { css } from '../../../../styled-system/css'
// Map GameType keys to internal game names
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
'battle-arena': 'matching',
'memory-lightning': 'memory-quiz',
'memory-quiz': 'memory-quiz',
'complement-race': 'complement-race',
'master-organizer': 'master-organizer',
}
@@ -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

@@ -23,10 +23,23 @@ export function GameCard({ gameType, config, variant = 'detailed', className }:
}
const handleGameClick = () => {
console.log(`[GameCard] Clicked on ${config.name}:`, {
activePlayerCount,
maxPlayers: config.maxPlayers,
isGameAvailable: isGameAvailable(),
configAvailable: config.available,
willNavigate: isGameAvailable() && config.available !== false,
url: config.url,
})
if (isGameAvailable() && config.available !== false) {
console.log('🔄 GameCard: Navigating with Next.js router (no page reload)')
// Use Next.js router for client-side navigation - this preserves fullscreen!
router.push(config.url)
} else {
console.warn('❌ GameCard: Navigation blocked', {
reason: !isGameAvailable() ? 'Player count mismatch' : 'Game not available',
})
}
}

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.2",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [