Compare commits

...

10 Commits

Author SHA1 Message Date
semantic-release-bot
3eaa84d157 chore(release): 3.15.2 [skip ci]
## [3.15.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.1...v3.15.2) (2025-10-15)

### Bug Fixes

* **memory-quiz:** prevent duplicate card processing from optimistic updates ([51676fc](51676fc15f))
2025-10-15 14:36:42 +00:00
Thomas Hallock
51676fc15f fix(memory-quiz): prevent duplicate card processing from optimistic updates
Fix race condition where the host would skip cards due to the effect
running twice on the same card index - once for the optimistic update
and potentially again for the server update.

The issue: When the host calls nextCard(), it immediately applies an
optimistic update that changes currentCardIndex. This triggers the effect
to re-run before the timer has even finished. Since isProcessingRef was
set to false right before calling nextCard(), the effect would start
processing the next card immediately, causing cards to be skipped.

Solution: Track the last processed card index in a ref (lastProcessedIndexRef)
and skip the effect if we're trying to process the same index again. This
ensures each card is only shown once, regardless of how many times the
effect runs due to state changes.

- Add lastProcessedIndexRef to track the last card we processed
- Check at start of effect if currentCardIndex === lastProcessedIndexRef
- Skip duplicate processing to prevent race conditions
- Remove unnecessary dependency on state.quizCards[currentCardIndex]
- Add detailed logging to help debug timing issues

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:35:48 -05:00
semantic-release-bot
82ca31029c chore(release): 3.15.1 [skip ci]
## [3.15.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.0...v3.15.1) (2025-10-15)

### Bug Fixes

* **memory-quiz:** synchronize card display across all players in multiplayer ([472f201](472f201088))
2025-10-15 14:26:34 +00:00
Thomas Hallock
472f201088 fix(memory-quiz): synchronize card display across all players in multiplayer
Fix race condition where each player's browser independently timed card
progression, causing desync where different players saw different numbers
of cards during the memorization phase.

Solution: Only the room creator controls card timing by sending NEXT_CARD
moves. All other players react to state.currentCardIndex changes from the
server, ensuring all players see the same cards at the same time.

- Add isRoomCreator flag to MemoryQuizContext
- Detect room creator in RoomMemoryQuizProvider
- Modify DisplayPhase to only call nextCard() if room creator or local mode
- Add debug logging to track timing control

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:25:40 -05:00
semantic-release-bot
86b75cba5a chore(release): 3.15.0 [skip ci]
## [3.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.4...v3.15.0) (2025-10-15)

### Features

* **memory-quiz:** add multiplayer support with redesigned scoreboards ([1cf4469](1cf44696c2))
* **memory-quiz:** show player emojis on cards to indicate who found them ([05bd11a](05bd11a133))

### Bug Fixes

* **arcade:** add defensive checks and update test fixtures ([a93d981](a93d981d1a))
2025-10-15 14:18:13 +00:00
Thomas Hallock
a93d981d1a fix(arcade): add defensive checks and update test fixtures
- Add defensive state corruption checks to RoomMemoryPairsProvider
- Update test fixtures to include userId field in GameMove objects
- Add git restore to allowed bash commands in local settings

These changes improve robustness when game state becomes corrupted
(e.g., from game type mismatches between room sessions).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:16:54 -05:00
Thomas Hallock
05bd11a133 feat(memory-quiz): show player emojis on cards to indicate who found them
Replace checkmark indicators with player emojis on correctly guessed cards
in the results view. This provides visual feedback about which team found
each number in both cooperative and competitive modes.

- Display team player emojis vertically stacked for multi-player teams
- Use rounded rectangle background instead of circle for better fit
- Set maxHeight and overflow:hidden to prevent clipping issues
- Fallback to checkmark if no player data available

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:16:54 -05:00
Thomas Hallock
1cf44696c2 feat(memory-quiz): add multiplayer support with redesigned scoreboards
- Add multiplayer state tracking (playerMetadata, playerScores, activePlayers)
- Add cooperative and competitive play modes
- Preserve multiplayer state through server-side validation
- Redesign scoreboard layout to stack players vertically with larger stats
- Add live scoreboard during gameplay (competitive mode)
- Add final leaderboard on results screen for both modes
- Track scores by userId to properly handle multi-player teams

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:16:54 -05:00
semantic-release-bot
297927401c chore(release): 3.14.4 [skip ci]
## [3.14.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.3...v3.14.4) (2025-10-15)

### Bug Fixes

* **memory-quiz:** prevent input lag during rapid typing in room mode ([b45139b](b45139b588))
2025-10-15 00:31:10 +00:00
Thomas Hallock
b45139b588 fix(memory-quiz): prevent input lag during rapid typing in room mode
When typing rapidly in room mode, users had to type each digit
8+ times before it registered. This was caused by reading stale
state.currentInput values during rapid keypresses before React
could re-render with the optimistically updated state.

Solution: Use a ref to track the current input value and update
it immediately when keys are pressed, before waiting for the
network round-trip and React re-render.

Changes:
- Add currentInputRef to track input value immediately
- Update ref in useEffect to stay in sync with state
- Use ref instead of state.currentInput in keyboard handlers
- Clear ref immediately when accepting/rejecting numbers

This fixes the async network validation issue where local state
updates were too slow for rapid user input.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 19:30:12 -05:00
19 changed files with 1390 additions and 144 deletions

View File

@@ -1,3 +1,37 @@
## [3.15.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.1...v3.15.2) (2025-10-15)
### Bug Fixes
* **memory-quiz:** prevent duplicate card processing from optimistic updates ([51676fc](https://github.com/antialias/soroban-abacus-flashcards/commit/51676fc15f5bc15cdb43393d3e66f7c5a0667868))
## [3.15.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.0...v3.15.1) (2025-10-15)
### Bug Fixes
* **memory-quiz:** synchronize card display across all players in multiplayer ([472f201](https://github.com/antialias/soroban-abacus-flashcards/commit/472f201088d82f92030273fadaf8a8e488820d6c))
## [3.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.4...v3.15.0) (2025-10-15)
### Features
* **memory-quiz:** add multiplayer support with redesigned scoreboards ([1cf4469](https://github.com/antialias/soroban-abacus-flashcards/commit/1cf44696c26473ce4ab2fc2039ff42f08c20edb6))
* **memory-quiz:** show player emojis on cards to indicate who found them ([05bd11a](https://github.com/antialias/soroban-abacus-flashcards/commit/05bd11a133706c9ed8c09c744da7ca8955fa979a))
### Bug Fixes
* **arcade:** add defensive checks and update test fixtures ([a93d981](https://github.com/antialias/soroban-abacus-flashcards/commit/a93d981d1ab3abed019b28cebe87525191313cc7))
## [3.14.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.3...v3.14.4) (2025-10-15)
### Bug Fixes
* **memory-quiz:** prevent input lag during rapid typing in room mode ([b45139b](https://github.com/antialias/soroban-abacus-flashcards/commit/b45139b588d0ab6df4d6c1003c1b65b634e2b041))
## [3.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.2...v3.14.3) (2025-10-15)

View File

@@ -69,7 +69,8 @@
"Bash(git reset:*)",
"Bash(lsof:*)",
"Bash(killall:*)",
"Bash(echo:*)"
"Bash(echo:*)",
"Bash(git restore:*)"
],
"deny": [],
"ask": []

View File

@@ -90,16 +90,19 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
case 'FLIP_CARD': {
// Optimistically flip the card
const card = state.gameCards.find((c) => c.id === move.data.cardId)
// Defensive check: ensure arrays exist
const gameCards = state.gameCards || []
const flippedCards = state.flippedCards || []
const card = gameCards.find((c) => c.id === move.data.cardId)
if (!card) return state
const newFlippedCards = [...state.flippedCards, card]
const newFlippedCards = [...flippedCards, card]
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime:
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
currentMoveStartTime: flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
showMismatchFeedback: false,
}
@@ -260,35 +263,51 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
applyMove: applyMoveOptimistically,
})
// Detect state corruption/mismatch (e.g., game type mismatch between sessions)
const hasStateCorruption =
!state.gameCards || !state.flippedCards || !Array.isArray(state.gameCards)
// Handle mismatch feedback timeout
useEffect(() => {
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
// Defensive check: ensure flippedCards exists
if (state.showMismatchFeedback && state.flippedCards?.length === 2) {
// After 1.5 seconds, send CLEAR_MISMATCH
// Server will validate that cards are still in mismatch state before clearing
const timeout = setTimeout(() => {
sendMove({
type: 'CLEAR_MISMATCH',
playerId: state.currentPlayer,
userId: viewerId || '',
data: {},
})
}, 1500)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
}, [
state.showMismatchFeedback,
state.flippedCards?.length,
sendMove,
state.currentPlayer,
viewerId,
])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = useCallback(
(cardId: string): boolean => {
// Defensive check: ensure required state exists
const flippedCards = state.flippedCards || []
const gameCards = state.gameCards || []
console.log('[RoomProvider][canFlipCard] Checking card:', {
cardId,
isGameActive,
isProcessingMove: state.isProcessingMove,
currentPlayer: state.currentPlayer,
hasRoomData: !!roomData,
flippedCardsCount: state.flippedCards.length,
flippedCardsCount: flippedCards.length,
})
if (!isGameActive || state.isProcessingMove) {
@@ -296,20 +315,20 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
return false
}
const card = state.gameCards.find((c) => c.id === cardId)
const card = gameCards.find((c) => c.id === cardId)
if (!card || card.matched) {
console.log('[RoomProvider][canFlipCard] Blocked: card not found or already matched')
return false
}
// Can't flip if already flipped
if (state.flippedCards.some((c) => c.id === cardId)) {
if (flippedCards.some((c) => c.id === cardId)) {
console.log('[RoomProvider][canFlipCard] Blocked: card already flipped')
return false
}
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) {
if (flippedCards.length >= 2) {
console.log('[RoomProvider][canFlipCard] Blocked: 2 cards already flipped')
return false
}
@@ -414,13 +433,14 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'START_GAME',
playerId: firstPlayer,
userId: viewerId || '',
data: {
cards,
activePlayers,
playerMetadata,
},
})
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove, viewerId])
const flipCard = useCallback(
(cardId: string) => {
@@ -441,6 +461,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const move = {
type: 'FLIP_CARD' as const,
playerId: state.currentPlayer, // Use the current player ID from game state (database player ID)
userId: viewerId || '',
data: { cardId },
}
console.log('[RoomProvider] Sending FLIP_CARD move via sendMove:', move)
@@ -466,13 +487,14 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'START_GAME',
playerId: firstPlayer,
userId: viewerId || '',
data: {
cards,
activePlayers,
playerMetadata,
},
})
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove, viewerId])
const setGameType = useCallback(
(gameType: typeof state.gameType) => {
@@ -481,10 +503,11 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'SET_CONFIG',
playerId,
userId: viewerId || '',
data: { field: 'gameType', value: gameType },
})
},
[activePlayers, sendMove]
[activePlayers, sendMove, viewerId]
)
const setDifficulty = useCallback(
@@ -493,10 +516,11 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'SET_CONFIG',
playerId,
userId: viewerId || '',
data: { field: 'difficulty', value: difficulty },
})
},
[activePlayers, sendMove]
[activePlayers, sendMove, viewerId]
)
const setTurnTimer = useCallback(
@@ -505,10 +529,11 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'SET_CONFIG',
playerId,
userId: viewerId || '',
data: { field: 'turnTimer', value: turnTimer },
})
},
[activePlayers, sendMove]
[activePlayers, sendMove, viewerId]
)
const goToSetup = useCallback(() => {
@@ -517,9 +542,10 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'GO_TO_SETUP',
playerId,
userId: viewerId || '',
data: {},
})
}, [activePlayers, state.currentPlayer, sendMove])
}, [activePlayers, state.currentPlayer, sendMove, viewerId])
const resumeGame = useCallback(() => {
// PAUSE/RESUME: Resume paused game if config unchanged
@@ -532,9 +558,10 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'RESUME_GAME',
playerId,
userId: viewerId || '',
data: {},
})
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove, viewerId])
const hoverCard = useCallback(
(cardId: string | null) => {
@@ -546,10 +573,11 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'HOVER_CARD',
playerId,
userId: viewerId || '',
data: { cardId },
})
},
[state.currentPlayer, activePlayers, sendMove]
[state.currentPlayer, activePlayers, sendMove, viewerId]
)
// NO MORE effectiveState merging! Just use session state directly with gameMode added
@@ -557,6 +585,100 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
gameMode: GameMode
}
// If state is corrupted, show error message instead of crashing
if (hasStateCorruption) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
textAlign: 'center',
minHeight: '400px',
}}
>
<div
style={{
fontSize: '48px',
marginBottom: '20px',
}}
>
</div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#dc2626',
}}
>
Game State Mismatch
</h2>
<p
style={{
fontSize: '16px',
color: '#6b7280',
marginBottom: '24px',
maxWidth: '500px',
}}
>
There's a mismatch between game types in this room. This usually happens when room members
are playing different games.
</p>
<div
style={{
background: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px',
maxWidth: '500px',
}}
>
<p
style={{
fontSize: '14px',
fontWeight: '600',
marginBottom: '8px',
}}
>
To fix this:
</p>
<ol
style={{
fontSize: '14px',
textAlign: 'left',
paddingLeft: '20px',
lineHeight: '1.6',
}}
>
<li>Make sure all room members are on the same game page</li>
<li>Try refreshing the page</li>
<li>If the issue persists, leave and rejoin the room</li>
</ol>
</div>
<button
onClick={() => window.location.reload()}
style={{
padding: '10px 20px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Refresh Page
</button>
</div>
)
}
const contextValue: MemoryPairsContextValue = {
state: effectiveState,
dispatch: () => {

View File

@@ -12,13 +12,18 @@ function calculateMaxColumns(numbers: number[]): number {
}
export function DisplayPhase() {
const { state, nextCard, showInputPhase, resetGame } = useMemoryQuiz()
const { state, nextCard, showInputPhase, resetGame, isRoomCreator } = 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 lastProcessedIndexRef = useRef(-1)
const appConfig = useAbacusConfig()
// In multiplayer room mode, only the room creator controls card timing
// In local mode (isRoomCreator === undefined), allow timing control
const shouldControlTiming = isRoomCreator === undefined || isRoomCreator === true
// Calculate maximum columns needed for this quiz set
const maxColumns = useMemo(() => {
const allNumbers = state.quizCards.map((card) => card.number)
@@ -34,21 +39,42 @@ export function DisplayPhase() {
const progressPercentage = (state.currentCardIndex / state.quizCards.length) * 100
useEffect(() => {
// Prevent processing the same card index multiple times
// This prevents race conditions from optimistic updates
if (state.currentCardIndex === lastProcessedIndexRef.current) {
console.log(
`DisplayPhase: Skipping duplicate processing of index ${state.currentCardIndex} (lastProcessed: ${lastProcessedIndexRef.current})`
)
return
}
if (state.currentCardIndex >= state.quizCards.length) {
showInputPhase?.()
// Only the room creator (or local mode) triggers phase transitions
if (shouldControlTiming) {
console.log(
`DisplayPhase: All cards shown (${state.quizCards.length}), transitioning to input phase`
)
showInputPhase?.()
}
return
}
// Prevent multiple concurrent executions
if (isProcessingRef.current) {
console.log(
`DisplayPhase: Already processing, skipping (index: ${state.currentCardIndex}, lastProcessed: ${lastProcessedIndexRef.current})`
)
return
}
// Mark this index as being processed
lastProcessedIndexRef.current = state.currentCardIndex
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}`
`DisplayPhase: Showing card ${state.currentCardIndex + 1}/${state.quizCards.length}, number: ${card.number} (isRoomCreator: ${isRoomCreator}, shouldControlTiming: ${shouldControlTiming})`
)
// Calculate adaptive timing based on display speed
@@ -67,15 +93,26 @@ export function DisplayPhase() {
`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))
// Only the room creator (or local mode) controls the timing
if (shouldControlTiming) {
// 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
// Don't hide the abacus - just advance to next card for smooth transition
console.log(
`DisplayPhase: Card ${state.currentCardIndex + 1} transitioning to next (controlled by ${isRoomCreator === undefined ? 'local mode' : 'room creator'})`
)
await new Promise((resolve) => setTimeout(resolve, transitionPause)) // Adaptive pause for visual transition
isProcessingRef.current = false
nextCard?.()
isProcessingRef.current = false
nextCard?.()
} else {
// Non-creator players just display the card, don't control timing
console.log(
`DisplayPhase: Non-creator player displaying card ${state.currentCardIndex + 1}, waiting for creator to advance`
)
isProcessingRef.current = false
}
}
showNextCard()
@@ -85,7 +122,8 @@ export function DisplayPhase() {
state.quizCards.length,
nextCard,
showInputPhase,
state.quizCards[state.currentCardIndex],
shouldControlTiming,
isRoomCreator,
])
return (

View File

@@ -1,11 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, 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'
)
@@ -107,7 +106,7 @@ export function InputPhase() {
const acceptCorrectNumber = useCallback(
(number: number) => {
acceptNumber?.(number)
setInput?.('')
// setInput('') is called inside acceptNumber action creator
setDisplayFeedback('correct')
setTimeout(() => setDisplayFeedback('neutral'), 500)
@@ -117,7 +116,7 @@ export function InputPhase() {
setTimeout(() => showResults?.(), 1000)
}
},
[acceptNumber, setInput, showResults, state.foundNumbers.length, state.correctAnswers.length]
[acceptNumber, showResults, state.foundNumbers.length, state.correctAnswers.length]
)
const handleIncorrectGuess = useCallback(() => {
@@ -131,7 +130,7 @@ export function InputPhase() {
}
rejectNumber?.()
setInput?.('')
// setInput('') is called inside rejectNumber action creator
setDisplayFeedback('incorrect')
setTimeout(() => setDisplayFeedback('neutral'), 500)
@@ -140,7 +139,7 @@ export function InputPhase() {
if (state.guessesRemaining - 1 === 0) {
setTimeout(() => showResults?.(), 1000)
}
}, [dispatch, rejectNumber, setInput, showResults, state.guessesRemaining, state.currentInput])
}, [state.currentInput, dispatch, rejectNumber, showResults, state.guessesRemaining])
// Simple keyboard event handlers that will be defined after callbacks
const handleKeyboardInput = useCallback(
@@ -150,6 +149,7 @@ export function InputPhase() {
// Only handle if input phase is active and guesses remain
if (state.guessesRemaining === 0) return
// Update input with new key
const newInput = state.currentInput + key
setInput?.(newInput)
@@ -369,6 +369,172 @@ export function InputPhase() {
</div>
</div>
{/* Live Scoreboard - Competitive Mode Only */}
{state.playMode === 'competitive' &&
state.activePlayers &&
state.activePlayers.length > 1 && (
<div
style={{
marginBottom: '16px',
padding: '12px',
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
borderRadius: '8px',
border: '2px solid #f59e0b',
}}
>
<div
style={{
fontSize: '12px',
fontWeight: 'bold',
color: '#92400e',
marginBottom: '8px',
textAlign: 'center',
}}
>
🏆 LIVE SCOREBOARD
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
{(() => {
// Group players by userId
const userTeams = new Map<
string,
{ userId: string; players: any[]; score: { correct: number; incorrect: number } }
>()
console.log('📊 [InputPhase] Building scoreboard:', {
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
playerScores: state.playerScores,
})
for (const playerId of state.activePlayers) {
const metadata = state.playerMetadata?.[playerId]
const userId = metadata?.userId
console.log('📊 [InputPhase] Processing player for scoreboard:', {
playerId,
metadata,
userId,
})
if (!userId) continue
if (!userTeams.has(userId)) {
userTeams.set(userId, {
userId,
players: [],
score: state.playerScores?.[userId] || { correct: 0, incorrect: 0 },
})
}
userTeams.get(userId)!.players.push(metadata)
}
console.log('📊 [InputPhase] UserTeams created:', {
count: userTeams.size,
teams: Array.from(userTeams.entries()),
})
// Sort teams by score
return Array.from(userTeams.values())
.sort((a, b) => {
const aScore = a.score.correct - a.score.incorrect * 0.5
const bScore = b.score.correct - b.score.incorrect * 0.5
return bScore - aScore
})
.map((team, index) => {
const netScore = team.score.correct - team.score.incorrect * 0.5
return (
<div
key={team.userId}
style={{
padding: '10px 12px',
background: index === 0 ? '#fef3c7' : 'white',
borderRadius: '8px',
border: index === 0 ? '2px solid #f59e0b' : '1px solid #e5e7eb',
}}
>
{/* Team header with rank and stats */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '18px' }}>
{index === 0 ? '👑' : `${index + 1}.`}
</span>
<span
style={{
fontWeight: 'bold',
fontSize: '16px',
color: '#1f2937',
}}
>
Score: {netScore.toFixed(1)}
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '14px',
}}
>
<span style={{ color: '#10b981', fontWeight: 'bold' }}>
{team.score.correct}
</span>
<span style={{ color: '#ef4444', fontWeight: 'bold' }}>
{team.score.incorrect}
</span>
</div>
</div>
{/* Players list */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
paddingLeft: '26px',
}}
>
{team.players.map((player, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
}}
>
<span style={{ fontSize: '16px' }}>{player?.emoji || '🎮'}</span>
<span
style={{
color: '#1f2937',
fontWeight: '500',
}}
>
{player?.name || `Player ${i + 1}`}
</span>
</div>
))}
</div>
</div>
)
})
})()}
</div>
</div>
)}
<div
style={{
position: 'relative',

View File

@@ -80,78 +80,78 @@ export function MemoryQuizGame() {
>
<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',
overflow: 'auto',
padding: '20px 8px',
minHeight: '100vh',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
}}
>
<div
style={{
maxWidth: '100%',
margin: '0 auto',
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({
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%',
overflow: 'auto',
})}
>
<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>
{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

@@ -147,26 +147,61 @@ export function ResultsCardGrid({ state }: ResultsCardGridProps) {
</div>
</div>
{/* Right/Wrong indicator overlay */}
{/* Player indicator overlay */}
<div
style={{
position: 'absolute',
top: '4px',
right: '4px',
width: '20px',
height: '20px',
borderRadius: '50%',
minWidth: wasFound ? '24px' : '20px',
minHeight: '20px',
maxHeight: '48px',
borderRadius: wasFound ? '8px' : '50%',
background: wasFound ? '#10b981' : '#ef4444',
color: 'white',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontSize: wasFound ? '14px' : '12px',
fontWeight: 'bold',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.2)',
padding: wasFound ? '2px' : '0',
gap: '1px',
overflow: 'hidden',
}}
>
{wasFound ? '✓' : '✗'}
{wasFound
? (() => {
// Get the userId who found this number
const foundByUserId = state.numberFoundBy?.[card.number]
if (!foundByUserId) return '✓'
// Get all players on that team
const teamPlayers = state.activePlayers
?.filter((playerId) => {
const metadata = state.playerMetadata?.[playerId]
return metadata?.userId === foundByUserId
})
.map((playerId) => state.playerMetadata?.[playerId])
.filter(Boolean)
if (!teamPlayers || teamPlayers.length === 0) return '✓'
// Display emojis (stacked vertically if multiple)
return teamPlayers.map((player, idx) => (
<span
key={idx}
style={{
lineHeight: '1',
fontSize: '14px',
}}
>
{player?.emoji || '🎮'}
</span>
))
})()
: '✗'}
</div>
{/* Number label overlay */}

View File

@@ -131,6 +131,374 @@ export function ResultsPhase() {
</div>
</div>
{/* Multiplayer Leaderboard - Competitive Mode */}
{state.playMode === 'competitive' &&
state.activePlayers &&
state.activePlayers.length > 1 && (
<div
style={{
marginBottom: '16px',
padding: '16px',
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
borderRadius: '12px',
border: '2px solid #f59e0b',
}}
>
<div
style={{
fontSize: '16px',
fontWeight: 'bold',
color: '#92400e',
marginBottom: '12px',
textAlign: 'center',
}}
>
🏆 FINAL LEADERBOARD
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{(() => {
// Group players by userId
const userTeams = new Map<
string,
{ userId: string; players: any[]; score: { correct: number; incorrect: number } }
>()
console.log('🏆 [ResultsPhase] Building leaderboard:', {
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
playerScores: state.playerScores,
})
for (const playerId of state.activePlayers) {
const metadata = state.playerMetadata?.[playerId]
const userId = metadata?.userId
console.log('🏆 [ResultsPhase] Processing player for leaderboard:', {
playerId,
metadata,
userId,
})
if (!userId) continue
if (!userTeams.has(userId)) {
userTeams.set(userId, {
userId,
players: [],
score: state.playerScores?.[userId] || { correct: 0, incorrect: 0 },
})
}
userTeams.get(userId)!.players.push(metadata)
}
console.log('🏆 [ResultsPhase] UserTeams created:', {
count: userTeams.size,
teams: Array.from(userTeams.entries()),
})
// Sort teams by score
return Array.from(userTeams.values())
.sort((a, b) => {
const aScore = a.score.correct - a.score.incorrect * 0.5
const bScore = b.score.correct - b.score.incorrect * 0.5
return bScore - aScore
})
.map((team, index) => {
const netScore = team.score.correct - team.score.incorrect * 0.5
return (
<div
key={team.userId}
style={{
padding: '14px 16px',
background:
index === 0
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 50%)'
: 'white',
borderRadius: '10px',
border: index === 0 ? '3px solid #f59e0b' : '1px solid #e5e7eb',
boxShadow: index === 0 ? '0 4px 12px rgba(245, 158, 11, 0.3)' : 'none',
}}
>
{/* Team header with rank and stats */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '10px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '24px', minWidth: '32px' }}>
{index === 0
? '🏆'
: index === 1
? '🥈'
: index === 2
? '🥉'
: `${index + 1}.`}
</span>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span
style={{
fontWeight: 'bold',
fontSize: index === 0 ? '20px' : '18px',
color: index === 0 ? '#f59e0b' : '#1f2937',
}}
>
{netScore.toFixed(1)}
</span>
{index === 0 && (
<span
style={{
fontSize: '11px',
color: '#92400e',
fontWeight: 'bold',
}}
>
CHAMPION
</span>
)}
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<span
style={{
color: '#10b981',
fontWeight: 'bold',
fontSize: '16px',
}}
>
{team.score.correct}
</span>
<span style={{ fontSize: '10px', color: '#6b7280' }}>correct</span>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<span
style={{
color: '#ef4444',
fontWeight: 'bold',
fontSize: '16px',
}}
>
{team.score.incorrect}
</span>
<span style={{ fontSize: '10px', color: '#6b7280' }}>wrong</span>
</div>
</div>
</div>
{/* Players list */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
paddingLeft: '42px',
}}
>
{team.players.map((player, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span style={{ fontSize: '18px' }}>{player?.emoji || '🎮'}</span>
<span
style={{
color: '#1f2937',
fontWeight: '500',
fontSize: '14px',
}}
>
{player?.name || `Player ${i + 1}`}
</span>
</div>
))}
</div>
</div>
)
})
})()}
</div>
</div>
)}
{/* Multiplayer Stats - Cooperative Mode */}
{state.playMode === 'cooperative' &&
state.activePlayers &&
state.activePlayers.length > 1 && (
<div
style={{
marginBottom: '16px',
padding: '16px',
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
borderRadius: '12px',
border: '2px solid #3b82f6',
}}
>
<div
style={{
fontSize: '16px',
fontWeight: 'bold',
color: '#1e3a8a',
marginBottom: '12px',
textAlign: 'center',
}}
>
🤝 TEAM CONTRIBUTIONS
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{(() => {
// Group players by userId
const userTeams = new Map<
string,
{ userId: string; players: any[]; score: { correct: number; incorrect: 0 } }
>()
console.log('🤝 [ResultsPhase] Building team contributions:', {
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
playerScores: state.playerScores,
})
for (const playerId of state.activePlayers) {
const metadata = state.playerMetadata?.[playerId]
const userId = metadata?.userId
console.log('🤝 [ResultsPhase] Processing player for contributions:', {
playerId,
metadata,
userId,
})
if (!userId) continue
if (!userTeams.has(userId)) {
userTeams.set(userId, {
userId,
players: [],
score: state.playerScores?.[userId] || { correct: 0, incorrect: 0 },
})
}
userTeams.get(userId)!.players.push(metadata)
}
console.log('🤝 [ResultsPhase] UserTeams created for contributions:', {
count: userTeams.size,
teams: Array.from(userTeams.entries()),
})
// Sort teams by correct answers
return Array.from(userTeams.values())
.sort((a, b) => b.score.correct - a.score.correct)
.map((team, index) => (
<div
key={team.userId}
style={{
padding: '12px 14px',
background: 'white',
borderRadius: '8px',
border: '1px solid #e5e7eb',
}}
>
{/* Team header with stats */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '16px', fontWeight: '600', color: '#6b7280' }}>
Team {index + 1}
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '14px',
}}
>
<span style={{ color: '#10b981', fontWeight: 'bold' }}>
{team.score.correct}
</span>
<span style={{ color: '#ef4444', fontWeight: 'bold' }}>
{team.score.incorrect}
</span>
</div>
</div>
{/* Players list */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
paddingLeft: '8px',
}}
>
{team.players.map((player, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
}}
>
<span style={{ fontSize: '18px' }}>{player?.emoji || '🎮'}</span>
<span
style={{
color: '#1f2937',
fontWeight: '500',
}}
>
{player?.name || `Player ${i + 1}`}
</span>
</div>
))}
</div>
</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} />

View File

@@ -69,6 +69,10 @@ export function SetupPhase() {
setConfig?.('selectedDifficulty', difficulty)
}
const handlePlayModeSelect = (playMode: 'cooperative' | 'competitive') => {
setConfig?.('playMode', playMode)
}
const handleStartQuiz = () => {
const quizCards = generateQuizCards(
state.selectedCount ?? 5,
@@ -150,6 +154,75 @@ export function SetupPhase() {
</div>
</div>
<div style={{ margin: '12px 0' }}>
<label
style={{
display: 'block',
fontWeight: 'bold',
marginBottom: '8px',
color: '#6b7280',
fontSize: '14px',
}}
>
Play Mode:
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '8px',
justifyContent: 'center',
}}
>
<button
key="cooperative"
type="button"
style={{
background: state.playMode === 'cooperative' ? '#10b981' : 'white',
color: state.playMode === 'cooperative' ? 'white' : '#1f2937',
border: '2px solid',
borderColor: state.playMode === 'cooperative' ? '#10b981' : '#d1d5db',
borderRadius: '8px',
padding: '8px 12px',
cursor: 'pointer',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
gap: '2px',
fontSize: '12px',
}}
onClick={() => handlePlayModeSelect('cooperative')}
title="Work together as a team to find all numbers"
>
<div style={{ fontWeight: 'bold', fontSize: '13px' }}>🤝 Cooperative</div>
<div style={{ fontSize: '10px', opacity: 0.8 }}>Work together</div>
</button>
<button
key="competitive"
type="button"
style={{
background: state.playMode === 'competitive' ? '#ef4444' : 'white',
color: state.playMode === 'competitive' ? 'white' : '#1f2937',
border: '2px solid',
borderColor: state.playMode === 'competitive' ? '#ef4444' : '#d1d5db',
borderRadius: '8px',
padding: '8px 12px',
cursor: 'pointer',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
gap: '2px',
fontSize: '12px',
}}
onClick={() => handlePlayModeSelect('competitive')}
title="Compete for the highest score"
>
<div style={{ fontWeight: 'bold', fontSize: '13px' }}>🏆 Competitive</div>
<div style={{ fontSize: '10px', opacity: 0.8 }}>Battle for score</div>
</button>
</div>
</div>
<div style={{ margin: '12px 0' }}>
<label
style={{

View File

@@ -10,6 +10,7 @@ export interface MemoryQuizContextValue {
// Computed values
isGameActive: boolean
isRoomCreator?: boolean // True if current user is room creator (controls timing in multiplayer)
// Action creators (to be implemented by providers)
// Local mode uses dispatch, room mode uses these action creators
@@ -25,7 +26,10 @@ export interface MemoryQuizContextValue {
rejectNumber?: () => void
setInput?: (input: string) => void
showResults?: () => void
setConfig?: (field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => void
setConfig?: (
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: any
) => void
}
// Create context

View File

@@ -1,11 +1,17 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import type { GameMove } from '@/lib/arcade/validation'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import {
buildPlayerMetadata as buildPlayerMetadataUtil,
buildPlayerOwnershipFromRoomData,
} from '@/lib/arcade/player-ownership.client'
import { initialState } from '../reducer'
import type { QuizCard, SorobanQuizState } from '../types'
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
@@ -45,6 +51,39 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
const cardCount = quizCards.length
// Initialize player scores for all active players (by userId, not playerId)
const activePlayers = move.data.activePlayers || []
const playerMetadata = move.data.playerMetadata || {}
console.log('🎯 [START_QUIZ] Initializing player scores:', {
activePlayers,
playerMetadata,
})
// Extract unique userIds from playerMetadata
const uniqueUserIds = new Set<string>()
for (const playerId of activePlayers) {
const metadata = playerMetadata[playerId]
console.log('🎯 [START_QUIZ] Processing player:', {
playerId,
metadata,
hasUserId: !!metadata?.userId,
})
if (metadata?.userId) {
uniqueUserIds.add(metadata.userId)
}
}
console.log('🎯 [START_QUIZ] Unique userIds found:', Array.from(uniqueUserIds))
// Initialize scores for each userId
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
}, {})
console.log('🎯 [START_QUIZ] Initialized playerScores:', playerScores)
return {
...state,
quizCards,
@@ -57,6 +96,10 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
// Multiplayer state
activePlayers,
playerMetadata,
playerScores,
}
}
@@ -72,26 +115,80 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
gamePhase: 'input',
}
case 'ACCEPT_NUMBER':
case 'ACCEPT_NUMBER': {
// Track scores by userId (not playerId) since we can't determine which player typed
// Defensive check: ensure state properties exist
const playerScores = state.playerScores || {}
const foundNumbers = state.foundNumbers || []
const numberFoundBy = state.numberFoundBy || {}
console.log('✅ [ACCEPT_NUMBER] Before update:', {
moveUserId: move.userId,
currentPlayerScores: playerScores,
number: move.data.number,
})
const newPlayerScores = { ...playerScores }
const newNumberFoundBy = { ...numberFoundBy }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
...currentScore,
correct: currentScore.correct + 1,
}
// Track who found this number
newNumberFoundBy[move.data.number] = move.userId
console.log('✅ [ACCEPT_NUMBER] After update:', {
userId: move.userId,
newScore: newPlayerScores[move.userId],
allScores: newPlayerScores,
numberFoundBy: move.data.number,
})
} else {
console.warn('⚠️ [ACCEPT_NUMBER] No userId in move!')
}
return {
...state,
foundNumbers: [...state.foundNumbers, move.data.number],
currentInput: '',
foundNumbers: [...foundNumbers, move.data.number],
playerScores: newPlayerScores,
numberFoundBy: newNumberFoundBy,
}
}
case 'REJECT_NUMBER':
case 'REJECT_NUMBER': {
// Track scores by userId (not playerId) since we can't determine which player typed
// Defensive check: ensure state properties exist
const playerScores = state.playerScores || {}
console.log('❌ [REJECT_NUMBER] Before update:', {
moveUserId: move.userId,
currentPlayerScores: playerScores,
})
const newPlayerScores = { ...playerScores }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
console.log('❌ [REJECT_NUMBER] After update:', {
userId: move.userId,
newScore: newPlayerScores[move.userId],
allScores: newPlayerScores,
})
} else {
console.warn('⚠️ [REJECT_NUMBER] No userId in move!')
}
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
}
case 'SET_INPUT':
return {
...state,
currentInput: move.data.input,
playerScores: newPlayerScores,
}
}
case 'SHOW_RESULTS':
return {
@@ -117,7 +214,7 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
case 'SET_CONFIG': {
const { field, value } = move.data as {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty'
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
value: any
}
return {
@@ -140,6 +237,14 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
// Get active player IDs as array
const activePlayers = Array.from(activePlayerIds)
// LOCAL-ONLY state for current input (not synced over network)
// This prevents sending a network request for every keystroke
const [localCurrentInput, setLocalCurrentInput] = useState('')
// Arcade session integration WITH room sync
const {
@@ -154,6 +259,13 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
applyMove: applyMoveOptimistically,
})
// Clear local input when game phase changes or when game resets
useEffect(() => {
if (state.gamePhase !== 'input') {
setLocalCurrentInput('')
}
}, [state.gamePhase])
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
@@ -163,33 +275,71 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
}
}, [state.prefixAcceptanceTimeout])
// Detect state corruption/mismatch (e.g., game type mismatch between sessions)
const hasStateCorruption =
!state.quizCards ||
!state.correctAnswers ||
!state.foundNumbers ||
!Array.isArray(state.quizCards)
// Computed values
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
// Build player metadata from room data and player map
const buildPlayerMetadata = useCallback(() => {
console.log('🔍 [buildPlayerMetadata] Starting:', {
roomData: roomData?.id,
activePlayers,
viewerId,
playersMapSize: players.size,
})
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
console.log('🔍 [buildPlayerMetadata] Player ownership:', playerOwnership)
const metadata = buildPlayerMetadataUtil(activePlayers, playerOwnership, players, viewerId)
console.log('🔍 [buildPlayerMetadata] Built metadata:', metadata)
return metadata
}, [activePlayers, players, roomData, viewerId])
// 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)
// Build player metadata for multiplayer
const playerMetadata = buildPlayerMetadata()
console.log('🚀 [startQuiz] Sending START_QUIZ move:', {
viewerId,
activePlayers,
playerMetadata,
numbers,
})
sendMove({
type: 'START_QUIZ',
playerId: viewerId || '',
playerId: TEAM_MOVE, // Team move - all players act together
userId: viewerId || '', // User who initiated
data: {
numbers, // Send to server
quizCards, // Keep for optimistic local update
activePlayers, // Send active players list
playerMetadata, // Send player display info
},
})
},
[viewerId, sendMove]
[viewerId, sendMove, activePlayers, buildPlayerMetadata]
)
const nextCard = useCallback(() => {
sendMove({
type: 'NEXT_CARD',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
@@ -197,16 +347,26 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const showInputPhase = useCallback(() => {
sendMove({
type: 'SHOW_INPUT_PHASE',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const acceptNumber = useCallback(
(number: number) => {
// Clear local input immediately
setLocalCurrentInput('')
console.log('🚀 [acceptNumber] Sending ACCEPT_NUMBER move:', {
viewerId,
number,
})
sendMove({
type: 'ACCEPT_NUMBER',
playerId: viewerId || '',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed correctly
data: { number },
})
},
@@ -214,28 +374,32 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
)
const rejectNumber = useCallback(() => {
// Clear local input immediately
setLocalCurrentInput('')
console.log('🚀 [rejectNumber] Sending REJECT_NUMBER move:', {
viewerId,
})
sendMove({
type: 'REJECT_NUMBER',
playerId: viewerId || '',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed incorrectly
data: {},
})
}, [viewerId, sendMove])
const setInput = useCallback(
(input: string) => {
sendMove({
type: 'SET_INPUT',
playerId: viewerId || '',
data: { input },
})
},
[viewerId, sendMove]
)
const setInput = useCallback((input: string) => {
// LOCAL ONLY - no network sync!
// This makes typing instant with zero network lag
setLocalCurrentInput(input)
}, [])
const showResults = useCallback(() => {
sendMove({
type: 'SHOW_RESULTS',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
@@ -243,24 +407,130 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const resetGame = useCallback(() => {
sendMove({
type: 'RESET_QUIZ',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const setConfig = useCallback(
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => {
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode', value: any) => {
sendMove({
type: 'SET_CONFIG',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { field, value },
})
},
[viewerId, sendMove]
)
// Merge network state with local input state
const mergedState = {
...state,
currentInput: localCurrentInput, // Override network state with local input
}
// If state is corrupted, show error message instead of crashing
if (hasStateCorruption) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
textAlign: 'center',
minHeight: '400px',
}}
>
<div
style={{
fontSize: '48px',
marginBottom: '20px',
}}
>
</div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#dc2626',
}}
>
Game State Mismatch
</h2>
<p
style={{
fontSize: '16px',
color: '#6b7280',
marginBottom: '24px',
maxWidth: '500px',
}}
>
There's a mismatch between game types in this room. This usually happens when room members
are playing different games.
</p>
<div
style={{
background: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px',
maxWidth: '500px',
}}
>
<p
style={{
fontSize: '14px',
fontWeight: '600',
marginBottom: '8px',
}}
>
To fix this:
</p>
<ol
style={{
fontSize: '14px',
textAlign: 'left',
paddingLeft: '20px',
lineHeight: '1.6',
}}
>
<li>Make sure all room members are on the same game page</li>
<li>Try refreshing the page</li>
<li>If the issue persists, leave and rejoin the room</li>
</ol>
</div>
<button
onClick={() => window.location.reload()}
style={{
padding: '10px 20px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Refresh Page
</button>
</div>
)
}
// Determine if current user is the room creator (controls card timing)
const isRoomCreator =
roomData?.members.find((member) => member.userId === viewerId)?.isCreator || false
const contextValue: MemoryQuizContextValue = {
state,
state: mergedState,
dispatch: () => {
// No-op - replaced with action creators
console.warn('dispatch() is deprecated in room mode, use action creators instead')
@@ -268,6 +538,7 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
isGameActive,
resetGame,
exitSession,
isRoomCreator, // Pass room creator flag to components
// Expose action creators for components to use
startQuiz,
nextCard,

View File

@@ -12,6 +12,13 @@ export const initialState: SorobanQuizState = {
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
// Multiplayer state
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: 'cooperative', // Default to cooperative
numberFoundBy: {},
// UI state
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
@@ -32,6 +39,8 @@ export function quizReducer(state: SorobanQuizState, action: QuizAction): Soroba
return { ...state, selectedCount: action.count }
case 'SET_DIFFICULTY':
return { ...state, selectedDifficulty: action.difficulty }
case 'SET_PLAY_MODE':
return { ...state, playMode: action.playMode }
case 'START_QUIZ':
return {
...state,
@@ -46,19 +55,41 @@ export function quizReducer(state: SorobanQuizState, action: QuizAction): Soroba
return { ...state, currentCardIndex: state.currentCardIndex + 1 }
case 'SHOW_INPUT_PHASE':
return { ...state, gamePhase: 'input' }
case 'ACCEPT_NUMBER':
case 'ACCEPT_NUMBER': {
// In competitive mode, track which player guessed correctly
const newPlayerScores = { ...state.playerScores }
if (state.playMode === 'competitive' && action.playerId) {
const currentScore = newPlayerScores[action.playerId] || { correct: 0, incorrect: 0 }
newPlayerScores[action.playerId] = {
...currentScore,
correct: currentScore.correct + 1,
}
}
return {
...state,
foundNumbers: [...state.foundNumbers, action.number],
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'REJECT_NUMBER': {
// In competitive mode, track which player guessed incorrectly
const newPlayerScores = { ...state.playerScores }
if (state.playMode === 'competitive' && action.playerId) {
const currentScore = newPlayerScores[action.playerId] || { correct: 0, incorrect: 0 }
newPlayerScores[action.playerId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
case 'REJECT_NUMBER':
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'SET_INPUT':
return { ...state, currentInput: action.input }
case 'SET_PREFIX_TIMEOUT':
@@ -89,6 +120,7 @@ export function quizReducer(state: SorobanQuizState, action: QuizAction): Soroba
displayTime: state.displayTime,
selectedCount: state.selectedCount,
selectedDifficulty: state.selectedDifficulty,
playMode: state.playMode, // Preserve play mode
// Preserve keyboard state across resets
hasPhysicalKeyboard: state.hasPhysicalKeyboard,
testingMode: state.testingMode,

View File

@@ -1,9 +1,16 @@
import type { PlayerMetadata } from '@/lib/arcade/player-ownership.client'
export interface QuizCard {
number: number
svgComponent: JSX.Element
svgComponent: JSX.Element | null
element: HTMLElement | null
}
export interface PlayerScore {
correct: number
incorrect: number
}
export interface SorobanQuizState {
// Core game data
cards: QuizCard[]
@@ -22,6 +29,13 @@ export interface SorobanQuizState {
currentInput: string
incorrectGuesses: number
// Multiplayer state
activePlayers: string[]
playerMetadata: Record<string, PlayerMetadata>
playerScores: Record<string, PlayerScore>
playMode: 'cooperative' | 'competitive'
numberFoundBy: Record<number, string> // Maps number to userId who found it
// UI state
gamePhase: 'setup' | 'display' | 'input' | 'results'
prefixAcceptanceTimeout: NodeJS.Timeout | null
@@ -43,11 +57,12 @@ export type QuizAction =
| { type: 'SET_DISPLAY_TIME'; time: number }
| { type: 'SET_SELECTED_COUNT'; count: number }
| { type: 'SET_DIFFICULTY'; difficulty: DifficultyLevel }
| { type: 'SET_PLAY_MODE'; playMode: 'cooperative' | 'competitive' }
| { type: 'START_QUIZ'; quizCards: QuizCard[] }
| { type: 'NEXT_CARD' }
| { type: 'SHOW_INPUT_PHASE' }
| { type: 'ACCEPT_NUMBER'; number: number }
| { type: 'REJECT_NUMBER' }
| { type: 'ACCEPT_NUMBER'; number: number; playerId?: string }
| { type: 'REJECT_NUMBER'; playerId?: string }
| { type: 'ADD_WRONG_GUESS_ANIMATION'; number: number }
| { type: 'CLEAR_WRONG_GUESS_ANIMATIONS' }
| { type: 'SET_INPUT'; input: string }

View File

@@ -9,13 +9,13 @@ export const GAMES_CONFIG = {
'memory-quiz': {
name: 'Memory Lightning',
fullName: 'Memory Lightning ⚡',
maxPlayers: 1,
maxPlayers: 4,
description: 'Test your memory speed with rapid-fire abacus calculations',
longDescription:
'Challenge yourself with lightning-fast memory tests. Perfect your mental math skills with this intense solo experience.',
'Challenge yourself or compete with friends in lightning-fast memory tests. Work together cooperatively or compete for the highest score!',
url: '/arcade/memory-quiz',
icon: '⚡',
chips: ['⭐ Beginner Friendly', '🔥 Speed Challenge', '🧮 Abacus Focus'],
chips: ['👥 Multiplayer', '🔥 Speed Challenge', '🧮 Abacus Focus'],
color: 'green',
gradient: 'linear-gradient(135deg, #dcfce7, #bbf7d0)',
borderColor: 'green.200',

View File

@@ -42,6 +42,7 @@ describe('useOptimisticGameState', () => {
const move: GameMove = {
type: 'INCREMENT',
playerId: 'test',
userId: 'test-user',
timestamp: Date.now(),
data: {},
}
@@ -65,6 +66,7 @@ describe('useOptimisticGameState', () => {
const move: GameMove = {
type: 'INCREMENT',
playerId: 'test',
userId: 'test-user',
timestamp: 123,
data: {},
}
@@ -100,6 +102,7 @@ describe('useOptimisticGameState', () => {
const move: GameMove = {
type: 'INCREMENT',
playerId: 'test',
userId: 'test-user',
timestamp: 123,
data: {},
}
@@ -133,6 +136,7 @@ describe('useOptimisticGameState', () => {
const move1: GameMove = {
type: 'INCREMENT',
playerId: 'test',
userId: 'test-user',
timestamp: 123,
data: {},
}
@@ -140,6 +144,7 @@ describe('useOptimisticGameState', () => {
const move2: GameMove = {
type: 'INCREMENT',
playerId: 'test',
userId: 'test-user',
timestamp: 124,
data: {},
}
@@ -184,6 +189,7 @@ describe('useOptimisticGameState', () => {
result.current.applyOptimisticMove({
type: 'INCREMENT',
playerId: 'test',
userId: 'test-user',
timestamp: 123,
data: {},
})
@@ -215,6 +221,7 @@ describe('useOptimisticGameState', () => {
result.current.applyOptimisticMove({
type: 'INCREMENT',
playerId: 'test',
userId: 'test-user',
timestamp: 123,
data: {},
})
@@ -245,6 +252,7 @@ describe('useOptimisticGameState', () => {
const move: GameMove = {
type: 'INCREMENT',
playerId: 'test',
userId: 'test-user',
timestamp: 123,
data: {},
}

View File

@@ -260,6 +260,7 @@ describe('session-manager', () => {
type: 'FLIP_CARD',
data: { cardId: '1' },
playerId: '1',
userId: mockUserId,
timestamp: Date.now(),
}

View File

@@ -30,10 +30,10 @@ export class MemoryQuizGameValidator
return this.validateShowInputPhase(state)
case 'ACCEPT_NUMBER':
return this.validateAcceptNumber(state, move.data.number)
return this.validateAcceptNumber(state, move.data.number, move.userId)
case 'REJECT_NUMBER':
return this.validateRejectNumber(state)
return this.validateRejectNumber(state, move.userId)
case 'SET_INPUT':
return this.validateSetInput(state, move.data.input)
@@ -83,6 +83,24 @@ export class MemoryQuizGameValidator
element: null,
}))
// Extract multiplayer data from move
const activePlayers = data.activePlayers || state.activePlayers || []
const playerMetadata = data.playerMetadata || state.playerMetadata || {}
// Initialize player scores for all active players (by userId)
const uniqueUserIds = new Set<string>()
for (const playerId of activePlayers) {
const metadata = playerMetadata[playerId]
if (metadata?.userId) {
uniqueUserIds.add(metadata.userId)
}
}
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
}, {})
const newState: SorobanQuizState = {
...state,
quizCards,
@@ -95,6 +113,11 @@ export class MemoryQuizGameValidator
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
// Multiplayer state
activePlayers,
playerMetadata,
playerScores,
numberFoundBy: {},
}
return {
@@ -143,7 +166,11 @@ export class MemoryQuizGameValidator
}
}
private validateAcceptNumber(state: SorobanQuizState, number: number): ValidationResult {
private validateAcceptNumber(
state: SorobanQuizState,
number: number,
userId?: string
): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
@@ -174,10 +201,28 @@ export class MemoryQuizGameValidator
}
}
// Update player scores (track by userId)
const playerScores = state.playerScores || {}
const newPlayerScores = { ...playerScores }
const numberFoundBy = state.numberFoundBy || {}
const newNumberFoundBy = { ...numberFoundBy }
if (userId) {
const currentScore = newPlayerScores[userId] || { correct: 0, incorrect: 0 }
newPlayerScores[userId] = {
...currentScore,
correct: currentScore.correct + 1,
}
// Track who found this number
newNumberFoundBy[number] = userId
}
const newState: SorobanQuizState = {
...state,
foundNumbers: [...state.foundNumbers, number],
currentInput: '',
playerScores: newPlayerScores,
numberFoundBy: newNumberFoundBy,
}
return {
@@ -186,7 +231,7 @@ export class MemoryQuizGameValidator
}
}
private validateRejectNumber(state: SorobanQuizState): ValidationResult {
private validateRejectNumber(state: SorobanQuizState, userId?: string): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
@@ -203,11 +248,23 @@ export class MemoryQuizGameValidator
}
}
// Update player scores (track by userId)
const playerScores = state.playerScores || {}
const newPlayerScores = { ...playerScores }
if (userId) {
const currentScore = newPlayerScores[userId] || { correct: 0, incorrect: 0 }
newPlayerScores[userId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
const newState: SorobanQuizState = {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
playerScores: newPlayerScores,
}
return {
@@ -289,7 +346,7 @@ export class MemoryQuizGameValidator
private validateSetConfig(
state: SorobanQuizState,
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty',
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: any
): ValidationResult {
// Can only change config during setup phase
@@ -320,6 +377,12 @@ export class MemoryQuizGameValidator
}
break
case 'playMode':
if (!['cooperative', 'competitive'].includes(value)) {
return { valid: false, error: `Invalid playMode: ${value}` }
}
break
default:
return { valid: false, error: `Unknown config field: ${field}` }
}
@@ -355,6 +418,13 @@ export class MemoryQuizGameValidator
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
// Multiplayer state
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: 'cooperative',
numberFoundBy: {},
// UI state
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,

View File

@@ -14,9 +14,17 @@ export interface ValidationResult {
newState?: unknown
}
/**
* Sentinel value for team moves where no specific player can be identified
* Used in free-for-all games where all of a user's players act as a team
*/
export const TEAM_MOVE = '__TEAM__' as const
export type TeamMoveSentinel = typeof TEAM_MOVE
export interface GameMove {
type: string
playerId: string
playerId: string | TeamMoveSentinel // Individual player (turn-based) or __TEAM__ (free-for-all)
userId: string // Room member/viewer who made the move
timestamp: number
data: unknown
}
@@ -128,7 +136,7 @@ export interface MemoryQuizResetQuizMove extends GameMove {
export interface MemoryQuizSetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty'
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
value: any
}
}

View File

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