Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f04a3b622 | ||
|
|
05a8e0a842 | ||
|
|
9dac9b7a36 | ||
|
|
b99e754395 | ||
|
|
3eaa84d157 | ||
|
|
51676fc15f | ||
|
|
82ca31029c | ||
|
|
472f201088 |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,3 +1,31 @@
|
||||
## [3.17.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.16.0...v3.17.0) (2025-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **memory-quiz:** persist game settings per-game across sessions ([05a8e0a](https://github.com/antialias/soroban-abacus-flashcards/commit/05a8e0a84272c6c45a4014413ee00726eb88b76a))
|
||||
|
||||
## [3.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.2...v3.16.0) (2025-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **arcade:** broadcast game selection changes to all room members ([b99e754](https://github.com/antialias/soroban-abacus-flashcards/commit/b99e7543952bb0d47f42e79dc4226b3c1280a0ee))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -111,6 +111,23 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
.returning()
|
||||
|
||||
// Broadcast game change to all room members
|
||||
if (body.gameName !== undefined) {
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
console.log(`[Settings API] Broadcasting game change to room ${roomId}: ${body.gameName}`)
|
||||
io.to(`room:${roomId}`).emit('room-game-changed', {
|
||||
roomId,
|
||||
gameName: body.gameName,
|
||||
gameConfig: body.gameConfig || {},
|
||||
})
|
||||
} catch (socketError) {
|
||||
console.error('[Settings API] Failed to broadcast game change:', socketError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If setting to retired, expel all non-owner members
|
||||
if (body.accessMode === 'retired') {
|
||||
const nonOwnerMembers = members.filter((m) => !m.isCreator)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
|
||||
@@ -238,6 +238,7 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active player IDs as array
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
@@ -246,6 +247,23 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
// This prevents sending a network request for every keystroke
|
||||
const [localCurrentInput, setLocalCurrentInput] = useState('')
|
||||
|
||||
// Merge saved game config from room with initialState
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const savedConfig = roomData?.gameConfig as Record<string, any> | null | undefined
|
||||
if (!savedConfig) return initialState
|
||||
|
||||
console.log('[RoomMemoryQuizProvider] Loading saved game config:', savedConfig)
|
||||
|
||||
return {
|
||||
...initialState,
|
||||
// Restore settings from saved config
|
||||
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
|
||||
displayTime: savedConfig.displayTime ?? initialState.displayTime,
|
||||
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
|
||||
playMode: savedConfig.playMode ?? initialState.playMode,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration WITH room sync
|
||||
const {
|
||||
state,
|
||||
@@ -255,7 +273,7 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
} = useArcadeSession<SorobanQuizState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
|
||||
initialState,
|
||||
initialState: mergedInitialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
@@ -421,8 +439,21 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Save setting to room's gameConfig for persistence
|
||||
if (roomData?.id) {
|
||||
const updatedConfig = {
|
||||
...(roomData.gameConfig as Record<string, any>),
|
||||
[field]: value,
|
||||
}
|
||||
console.log('[RoomMemoryQuizProvider] Saving game config:', updatedConfig)
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
}
|
||||
},
|
||||
[viewerId, sendMove]
|
||||
[viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
// Merge network state with local input state
|
||||
@@ -525,6 +556,10 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Determine if current user is the room creator (controls card timing)
|
||||
const isRoomCreator =
|
||||
roomData?.members.find((member) => member.userId === viewerId)?.isCreator || false
|
||||
|
||||
const contextValue: MemoryQuizContextValue = {
|
||||
state: mergedState,
|
||||
dispatch: () => {
|
||||
@@ -534,6 +569,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,
|
||||
|
||||
@@ -446,6 +446,23 @@ export function useRoomData() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleRoomGameChanged = (data: {
|
||||
roomId: string
|
||||
gameName: string | null
|
||||
gameConfig: Record<string, unknown>
|
||||
}) => {
|
||||
console.log('[useRoomData] Room game changed:', data)
|
||||
if (data.roomId === roomData?.id) {
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
gameName: data.gameName,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('room-joined', handleRoomJoined)
|
||||
socket.on('member-joined', handleMemberJoined)
|
||||
socket.on('member-left', handleMemberLeft)
|
||||
@@ -455,6 +472,7 @@ export function useRoomData() {
|
||||
socket.on('report-submitted', handleReportSubmitted)
|
||||
socket.on('room-invitation-received', handleInvitationReceived)
|
||||
socket.on('join-request-submitted', handleJoinRequestSubmitted)
|
||||
socket.on('room-game-changed', handleRoomGameChanged)
|
||||
|
||||
return () => {
|
||||
socket.off('room-joined', handleRoomJoined)
|
||||
@@ -466,6 +484,7 @@ export function useRoomData() {
|
||||
socket.off('report-submitted', handleReportSubmitted)
|
||||
socket.off('room-invitation-received', handleInvitationReceived)
|
||||
socket.off('join-request-submitted', handleJoinRequestSubmitted)
|
||||
socket.off('room-game-changed', handleRoomGameChanged)
|
||||
}
|
||||
}, [socket, roomData?.id, queryClient])
|
||||
|
||||
@@ -646,3 +665,34 @@ export function useClearRoomGame() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update game config for current room (game-specific settings)
|
||||
*/
|
||||
async function updateGameConfigApi(params: {
|
||||
roomId: string
|
||||
gameConfig: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gameConfig: params.gameConfig,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to update game config')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Update game config for current room
|
||||
* This allows games to persist their settings (e.g., difficulty, card count)
|
||||
*/
|
||||
export function useUpdateGameConfig() {
|
||||
return useMutation({
|
||||
mutationFn: updateGameConfigApi,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.15.0",
|
||||
"version": "3.17.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user