Compare commits

...

38 Commits

Author SHA1 Message Date
semantic-release-bot
f3080b50d9 chore(release): 3.17.11 [skip ci]
## [3.17.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.10...v3.17.11) (2025-10-15)

### Bug Fixes

* **memory-quiz:** fix playMode persistence by updating validator ([de0efd5](de0efd5932))
2025-10-15 17:51:21 +00:00
Thomas Hallock
de0efd5932 fix(memory-quiz): fix playMode persistence by updating validator
ROOT CAUSE FOUND:
The MemoryQuizGameValidator.getInitialState() method was hardcoding
playMode to 'cooperative' and not accepting it as a config parameter.

Even though socket-server.ts was passing playMode from the saved config,
the validator's TypeScript signature didn't include it:

BEFORE:
```typescript
getInitialState(config: {
  selectedCount: number
  displayTime: number
  selectedDifficulty: DifficultyLevel
}): SorobanQuizState {
  return {
    // ...
    playMode: 'cooperative',  // ← ALWAYS HARDCODED!
  }
}
```

AFTER:
```typescript
getInitialState(config: {
  selectedCount: number
  displayTime: number
  selectedDifficulty: DifficultyLevel
  playMode?: 'cooperative' | 'competitive'  // ← NEW!
}): SorobanQuizState {
  return {
    // ...
    playMode: config.playMode || 'cooperative',  // ← USES CONFIG VALUE!
  }
}
```

Also added comprehensive debug logging throughout the flow:
- socket-server.ts: logs room.gameConfig, extracted config, and resulting playMode
- RoomMemoryQuizProvider.tsx: logs roomData.gameConfig and merged state
- MemoryQuizGameValidator.ts: logs config received and playMode returned

This will help identify any remaining persistence issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:50:25 -05:00
semantic-release-bot
c9e5c473e6 chore(release): 3.17.10 [skip ci]
## [3.17.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.9...v3.17.10) (2025-10-15)

### Bug Fixes

* **memory-quiz:** persist playMode setting across game switches ([487ca7f](487ca7fba6))
2025-10-15 17:47:48 +00:00
Thomas Hallock
487ca7fba6 fix(memory-quiz): persist playMode setting across game switches
The socket-server was missing playMode when creating the initial session
for memory-quiz games. It was only loading selectedCount, displayTime, and
selectedDifficulty from the saved config, causing playMode to always reset
to the default 'cooperative' even when 'competitive' was saved.

Now includes playMode in the initial state config:
- selectedCount
- displayTime
- selectedDifficulty
- playMode (NEW)

This ensures the playMode setting persists when users:
1. Set playMode to 'competitive'
2. Go back to game selection
3. Select memory-quiz again
4. PlayMode is still 'competitive' (not reset to 'cooperative')

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:46:48 -05:00
semantic-release-bot
8f7eebce4b chore(release): 3.17.9 [skip ci]
## [3.17.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.8...v3.17.9) (2025-10-15)

### Bug Fixes

* **arcade:** read nested gameConfig correctly when creating sessions ([94ef392](94ef39234d))
2025-10-15 17:45:11 +00:00
Thomas Hallock
94ef39234d fix(arcade): read nested gameConfig correctly when creating sessions
The session initialization was looking for settings at the wrong level:
- Was reading: room.gameConfig.gameType (undefined, falls back to default)
- Should read: room.gameConfig.matching.gameType (saved value)

gameConfig is structured as:
{
  "matching": { "gameType": "...", "difficulty": ..., "turnTimer": ... },
  "memory-quiz": { "selectedCount": ..., "displayTime": ..., ... }
}

This caused the session to be created with default settings even though
the settings were saved in the database. The client would load the correct
settings from roomData.gameConfig, but then the socket would immediately
overwrite them with the session's default state.

Now properly accesses the nested config for each game type.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:44:10 -05:00
semantic-release-bot
6d14dd8b47 chore(release): 3.17.8 [skip ci]
## [3.17.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.7...v3.17.8) (2025-10-15)

### Bug Fixes

* **arcade:** preserve game settings when returning to game selection ([0ee7739](0ee7739091))
2025-10-15 17:42:27 +00:00
Thomas Hallock
0ee7739091 fix(arcade): preserve game settings when returning to game selection
When users clicked "back to game selection", the clearRoomGameApi function
was sending both gameName: null AND gameConfig: null to the server. This
destroyed all saved game settings (like gameType, difficulty, etc.).

Now clearRoomGameApi only sends gameName: null and preserves gameConfig,
so settings persist when users select a game again.

Root cause discovered via comprehensive database-level logging that traced
the exact data flow through the system.

Fixes settings persistence bug in room mode.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:41:36 -05:00
Thomas Hallock
5c135358fc debug(arcade): add comprehensive database-level logging for gameConfig
Add detailed logging at every layer to trace gameConfig through the system:

Server-side (Settings API):
- Log incoming PATCH request body
- Log database state BEFORE update
- Log what will be written to database
- Log database state AFTER update

Server-side (Current Room API):
- Log what's READ from database when fetching room

Client-side:
- Track roomData.gameConfig changes with useEffect

This will show us exactly when and where gameConfig is being overwritten.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:36:36 -05:00
semantic-release-bot
74554c3669 chore(release): 3.17.7 [skip ci]
## [3.17.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.6...v3.17.7) (2025-10-15)

### Bug Fixes

* **arcade:** prevent gameConfig from being overwritten when switching games ([a89d3a9](a89d3a9701))
2025-10-15 17:33:21 +00:00
Thomas Hallock
a89d3a9701 fix(arcade): prevent gameConfig from being overwritten when switching games
Root cause: setRoomGameApi was sending `gameConfig: {}` when gameConfig
was undefined, which overwrote all saved settings in the database.

Changes:
- Client: Only include gameConfig in request body if explicitly provided
- Server: Only include gameConfig in socket broadcast if provided
- Client handler: Update gameConfig from broadcast if present

This preserves all game settings (difficulty, card count, etc.) when
switching between games in a room.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:32:31 -05:00
semantic-release-bot
180e213d00 chore(release): 3.17.6 [skip ci]
## [3.17.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.5...v3.17.6) (2025-10-15)

### Code Refactoring

* **logging:** use JSON.stringify for all object logging ([c33698c](c33698ce52))
2025-10-15 17:30:09 +00:00
Thomas Hallock
c33698ce52 refactor(logging): use JSON.stringify for all object logging
Replace collapsed object logging with JSON.stringify to ensure full
object details are visible when console logs are copied/pasted.

This affects all settings persistence logging:
- Loading settings from database
- Saving settings to database
- API calls to server
- Cache updates

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:29:08 -05:00
semantic-release-bot
5b4cb7d35a chore(release): 3.17.5 [skip ci]
## [3.17.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.4...v3.17.5) (2025-10-15)

### Bug Fixes

* **arcade:** implement settings persistence for matching game ([08fe432](08fe4326a6))
2025-10-15 16:04:05 +00:00
Thomas Hallock
eacbafb1ea debug(arcade): add detailed logging for settings persistence
Add comprehensive console logging to trace the settings persistence flow:
- Load settings from database on initialization
- Save settings to database when changed (gameType, difficulty, turnTimer)
- API calls to server with full request/response logging

This will help diagnose if settings are being persisted correctly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 11:03:07 -05:00
Thomas Hallock
08fe4326a6 fix(arcade): implement settings persistence for matching game
- Add useUpdateGameConfig hook and database saves to RoomMemoryPairsProvider
- Load saved settings from gameConfig['matching'] on init
- Save gameType, difficulty, and turnTimer changes to database
- Apply lint fixes: use dot notation instead of bracket notation

Matching game now persists settings when switching between games,
matching the behavior of memory-quiz.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 11:03:07 -05:00
semantic-release-bot
fabb33252c chore(release): 3.17.4 [skip ci]
## [3.17.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.3...v3.17.4) (2025-10-15)

### Bug Fixes

* **matching:** add settings persistence to matching game ([00dcb87](00dcb872b7))
2025-10-15 15:16:36 +00:00
Thomas Hallock
00dcb872b7 fix(matching): add settings persistence to matching game
The matching game was not saving settings to the database at all.
When you changed gameType, difficulty, or turnTimer, it only sent
a move to the arcade session but never saved to the database.

This adds the same persistence logic that memory-quiz uses:

**On Load:**
- Reads settings from gameConfig['matching'] in the database
- Merges with initialState
- Passes to useArcadeSession

**On Change:**
- Sends SET_CONFIG move (for real-time sync)
- Saves to gameConfig['matching'] via updateGameConfig
- Updates TanStack Query cache

Changes:
- Import useUpdateGameConfig hook
- Add mergedInitialState with settings from database
- Save settings in setGameType, setDifficulty, setTurnTimer

Now settings persist when switching between games!

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 10:15:42 -05:00
semantic-release-bot
ea23651cb6 chore(release): 3.17.3 [skip ci]
## [3.17.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.2...v3.17.3) (2025-10-15)

### Bug Fixes

* **arcade:** preserve gameConfig when switching games ([2273c71](2273c71a87))

### Code Refactoring

* remove verbose console logging for cleaner debugging ([9cb5fdd](9cb5fdd2fa))
2025-10-15 15:13:35 +00:00
Thomas Hallock
2273c71a87 fix(arcade): preserve gameConfig when switching games
**ROOT CAUSE:**
When switching games, setRoomGame was called with gameConfig: {},
which OVERWROTE the entire gameConfig in the database, destroying
all saved settings for ALL games.

**THE FIX:**
Remove gameConfig parameter from setRoomGame call - only change the
game name, preserve all existing settings.

**ADDED DEBUG LOGGING:**
Added detailed logging in RoomMemoryQuizProvider to help diagnose
settings persistence issues:
- Log gameConfig on component init
- Log what settings are being loaded
- Log what settings are being saved
- Log the full updated gameConfig

Changes:
- src/app/arcade/room/page.tsx: Don't pass gameConfig when switching games
- src/app/arcade/memory-quiz/context/RoomMemoryQuizProvider.tsx: Added debug logs

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 10:12:33 -05:00
Thomas Hallock
9cb5fdd2fa refactor: remove verbose console logging for cleaner debugging
Removed excessive console.log statements from:
- RoomMemoryQuizProvider.tsx: Removed ~14 verbose logs related to player
  metadata, scores, and move processing
- useRoomData.ts: Removed logs for moderation events and player updates

Kept critical logs for debugging settings persistence:
- Loading saved game config
- Saving game config
- Room game changed
- Cache updates

This cleanup makes console output much more manageable when debugging
settings persistence issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 10:04:24 -05:00
semantic-release-bot
73c54a7ebc chore(release): 3.17.2 [skip ci]
## [3.17.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.1...v3.17.2) (2025-10-15)

### Bug Fixes

* **room-data:** update query cache when gameConfig changes ([7cea297](7cea297095))
2025-10-15 15:01:02 +00:00
Thomas Hallock
7cea297095 fix(room-data): update query cache when gameConfig changes
The issue was that useUpdateGameConfig was saving settings to the database
but not updating the TanStack Query cache. This meant that when components
re-mounted (e.g., when switching games), they would read stale data from
the cache instead of the newly saved settings.

Changes:
- Added onSuccess callback to useUpdateGameConfig to update the cache
- Added gameConfig field to RoomData interface
- Updated all API functions to include gameConfig in returned data:
  - fetchCurrentRoom
  - createRoomApi
  - joinRoomApi
  - getRoomByCodeApi

Now when settings are saved, the cache is immediately updated, so switching
games and returning shows the correct saved settings.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 10:00:05 -05:00
semantic-release-bot
019d36a0ab chore(release): 3.17.1 [skip ci]
## [3.17.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.0...v3.17.1) (2025-10-15)

### Bug Fixes

* **arcade-rooms:** navigate to invite link after room creation ([1922b21](1922b2122b))
* **memory-quiz:** scope game settings by game name for proper persistence ([3dfe54f](3dfe54f1cb))
2025-10-15 14:51:46 +00:00
Thomas Hallock
1922b2122b fix(arcade-rooms): navigate to invite link after room creation
Previously, when creating a new room, users were navigated to
/arcade-rooms/{roomId}, which is the direct room route.

Now users are navigated to /join/{code}, which is the invite link
format. This provides a better user experience as it follows the
same flow as joining via an invite link.

Changes:
- Changed router.push from /arcade-rooms/{id} to /join/{code}

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:50:53 -05:00
Thomas Hallock
3dfe54f1cb fix(memory-quiz): scope game settings by game name for proper persistence
Previously, settings were stored at the root of gameConfig, causing each
game to overwrite the other's settings when switching between games.

Now settings are stored under gameConfig['memory-quiz'], allowing each
game to maintain its own settings independently. When you switch from
memory-quiz to another game and back, the memory-quiz settings are
preserved exactly as you left them.

Changes:
- Load settings from gameConfig['memory-quiz'] instead of root gameConfig
- Save settings to gameConfig['memory-quiz'] to avoid overwriting other games
- Added comments explaining the scoping strategy

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:50:13 -05:00
semantic-release-bot
5f04a3b622 chore(release): 3.17.0 [skip ci]
## [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](05a8e0a842))
2025-10-15 14:46:51 +00:00
Thomas Hallock
05a8e0a842 feat(memory-quiz): persist game settings per-game across sessions
Implement per-game settings persistence so that when users switch between
games and come back, their settings are restored. Settings are saved to
the room's gameConfig field in the database.

Changes:
- Add useUpdateGameConfig hook to save settings to room
- Load settings from roomData.gameConfig on provider initialization
- Merge saved config with initialState using useMemo
- Save settings to database when setConfig is called
- Settings persist across:
  - Game switches (memory-quiz -> matching -> memory-quiz)
  - Page refreshes
  - New arcade sessions

Settings saved: selectedCount, displayTime, selectedDifficulty, playMode

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:45:56 -05:00
semantic-release-bot
9dac9b7a36 chore(release): 3.16.0 [skip ci]
## [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](b99e754395))
2025-10-15 14:44:39 +00:00
Thomas Hallock
b99e754395 feat(arcade): broadcast game selection changes to all room members
Fix issue where game selection by the host was not synchronized to other
room members. When the host selects a game, all players now see the change
in real-time via socket.io.

Server changes:
- Add 'room-game-changed' socket broadcast when gameName is updated
- Emit to all members in the room channel when game is set/changed

Client changes:
- Add socket listener for 'room-game-changed' event in useRoomData
- Update local cache when game change is received
- Room page automatically re-renders with new game selection

This ensures all players stay synchronized when the host selects or changes
the game for the room.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:42:39 -05:00
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
25 changed files with 1929 additions and 193 deletions

View File

@@ -1,3 +1,127 @@
## [3.17.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.10...v3.17.11) (2025-10-15)
### Bug Fixes
* **memory-quiz:** fix playMode persistence by updating validator ([de0efd5](https://github.com/antialias/soroban-abacus-flashcards/commit/de0efd59321ec779cddb900724035884290419b7))
## [3.17.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.9...v3.17.10) (2025-10-15)
### Bug Fixes
* **memory-quiz:** persist playMode setting across game switches ([487ca7f](https://github.com/antialias/soroban-abacus-flashcards/commit/487ca7fba62e370c85bc3779ca8a96eb2c2cc3e3))
## [3.17.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.8...v3.17.9) (2025-10-15)
### Bug Fixes
* **arcade:** read nested gameConfig correctly when creating sessions ([94ef392](https://github.com/antialias/soroban-abacus-flashcards/commit/94ef39234d362b82e032cb69d3561b9fcb436eaf))
## [3.17.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.7...v3.17.8) (2025-10-15)
### Bug Fixes
* **arcade:** preserve game settings when returning to game selection ([0ee7739](https://github.com/antialias/soroban-abacus-flashcards/commit/0ee7739091d60580d2f98cfe288b8586b03348f3))
## [3.17.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.6...v3.17.7) (2025-10-15)
### Bug Fixes
* **arcade:** prevent gameConfig from being overwritten when switching games ([a89d3a9](https://github.com/antialias/soroban-abacus-flashcards/commit/a89d3a970137471e2652de992c45370dbb97416d))
## [3.17.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.5...v3.17.6) (2025-10-15)
### Code Refactoring
* **logging:** use JSON.stringify for all object logging ([c33698c](https://github.com/antialias/soroban-abacus-flashcards/commit/c33698ce52ebdc18ce3a0d856f9241c7389ed651))
## [3.17.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.4...v3.17.5) (2025-10-15)
### Bug Fixes
* **arcade:** implement settings persistence for matching game ([08fe432](https://github.com/antialias/soroban-abacus-flashcards/commit/08fe4326a6a7c484b9058a241f4ff79b3fb5125f))
## [3.17.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.3...v3.17.4) (2025-10-15)
### Bug Fixes
* **matching:** add settings persistence to matching game ([00dcb87](https://github.com/antialias/soroban-abacus-flashcards/commit/00dcb872b7e70bdb7de301b56fe42195e6ee923f))
## [3.17.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.2...v3.17.3) (2025-10-15)
### Bug Fixes
* **arcade:** preserve gameConfig when switching games ([2273c71](https://github.com/antialias/soroban-abacus-flashcards/commit/2273c71a872a5122d0b2023835fe30640106048e))
### Code Refactoring
* remove verbose console logging for cleaner debugging ([9cb5fdd](https://github.com/antialias/soroban-abacus-flashcards/commit/9cb5fdd2fa43560adc32dd052f47a7b06b2c5b69))
## [3.17.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.1...v3.17.2) (2025-10-15)
### Bug Fixes
* **room-data:** update query cache when gameConfig changes ([7cea297](https://github.com/antialias/soroban-abacus-flashcards/commit/7cea297095b78d74f5b77ca83489ec1be684a486))
## [3.17.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.0...v3.17.1) (2025-10-15)
### Bug Fixes
* **arcade-rooms:** navigate to invite link after room creation ([1922b21](https://github.com/antialias/soroban-abacus-flashcards/commit/1922b2122bb1bc4aeada7526d8c46aa89024bb00))
* **memory-quiz:** scope game settings by game name for proper persistence ([3dfe54f](https://github.com/antialias/soroban-abacus-flashcards/commit/3dfe54f1cb89bd636e763e1c5acb03776f97c011))
## [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)
### 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)

View File

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

View File

@@ -27,6 +27,36 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
const viewerId = await getViewerId()
const body = await req.json()
console.log(
'[Settings API] PATCH request received:',
JSON.stringify(
{
roomId,
body,
},
null,
2
)
)
// Read current room state from database BEFORE any changes
const [currentRoom] = await db
.select()
.from(schema.arcadeRooms)
.where(eq(schema.arcadeRooms.id, roomId))
console.log(
'[Settings API] Current room state in database BEFORE update:',
JSON.stringify(
{
gameName: currentRoom?.gameName,
gameConfig: currentRoom?.gameConfig,
},
null,
2
)
)
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
@@ -97,6 +127,11 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
updateData.gameConfig = body.gameConfig
}
console.log(
'[Settings API] Update data to be written to database:',
JSON.stringify(updateData, null, 2)
)
// If game is being changed (or cleared), delete the existing arcade session
// This ensures a fresh session will be created with the new game settings
if (body.gameName !== undefined) {
@@ -111,6 +146,45 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
.where(eq(schema.arcadeRooms.id, roomId))
.returning()
console.log(
'[Settings API] Room state in database AFTER update:',
JSON.stringify(
{
gameName: updatedRoom.gameName,
gameConfig: updatedRoom.gameConfig,
},
null,
2
)
)
// 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}`)
const broadcastData: {
roomId: string
gameName: string | null
gameConfig?: Record<string, unknown>
} = {
roomId,
gameName: body.gameName,
}
// Only include gameConfig if it was explicitly provided
if (body.gameConfig !== undefined) {
broadcastData.gameConfig = body.gameConfig
}
io.to(`room:${roomId}`).emit('room-game-changed', broadcastData)
} 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)

View File

@@ -28,6 +28,19 @@ export async function GET() {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
console.log(
'[Current Room API] Room data READ from database:',
JSON.stringify(
{
roomId,
gameName: room.gameName,
gameConfig: room.gameConfig,
},
null,
2
)
)
// Get members
const members = await getRoomMembers(roomId)

View File

@@ -70,7 +70,7 @@ export default function RoomBrowserPage() {
}
const data = await response.json()
router.push(`/arcade-rooms/${data.room.id}`)
router.push(`/join/${data.room.code}`)
} catch (err) {
console.error('Failed to create room:', err)
showError('Failed to create room', err instanceof Error ? err.message : undefined)

View File

@@ -2,7 +2,7 @@
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import {
buildPlayerMetadata as buildPlayerMetadataUtil,
@@ -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,
}
@@ -237,6 +240,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData() // Fetch room data for room-based play
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
@@ -244,8 +248,77 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
// NO LOCAL STATE - Configuration lives in session state
// Changes are sent as moves and synchronized across all room members
// Track roomData.gameConfig changes
useEffect(() => {
console.log(
'[RoomMemoryPairsProvider] roomData.gameConfig changed:',
JSON.stringify(
{
gameConfig: roomData?.gameConfig,
roomId: roomData?.id,
gameName: roomData?.gameName,
},
null,
2
)
)
}, [roomData?.gameConfig, roomData?.id, roomData?.gameName])
// Merge saved game config from room with initialState
// Settings are scoped by game name to preserve settings when switching games
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
console.log(
'[RoomMemoryPairsProvider] Loading settings from database:',
JSON.stringify(
{
gameConfig,
roomId: roomData?.id,
},
null,
2
)
)
if (!gameConfig) {
console.log('[RoomMemoryPairsProvider] No gameConfig, using initialState')
return initialState
}
// Get settings for this specific game (matching)
const savedConfig = gameConfig.matching as Record<string, any> | null | undefined
console.log(
'[RoomMemoryPairsProvider] Saved config for matching:',
JSON.stringify(savedConfig, null, 2)
)
if (!savedConfig) {
console.log('[RoomMemoryPairsProvider] No saved config for matching, using initialState')
return initialState
}
const merged = {
...initialState,
// Restore settings from saved config
gameType: savedConfig.gameType ?? initialState.gameType,
difficulty: savedConfig.difficulty ?? initialState.difficulty,
turnTimer: savedConfig.turnTimer ?? initialState.turnTimer,
}
console.log(
'[RoomMemoryPairsProvider] Merged state:',
JSON.stringify(
{
gameType: merged.gameType,
difficulty: merged.difficulty,
turnTimer: merged.turnTimer,
},
null,
2
)
)
return merged
}, [roomData?.gameConfig])
// Arcade session integration WITH room sync
const {
@@ -256,39 +329,55 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
} = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
initialState,
initialState: mergedInitialState,
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 +385,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 +503,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 +531,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,49 +557,152 @@ 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) => {
console.log('[RoomMemoryPairsProvider] setGameType called:', gameType)
// Use first active player as playerId, or empty string if none
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
userId: viewerId || '',
data: { field: 'gameType', value: gameType },
})
// Save setting to room's gameConfig for persistence
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
const updatedConfig = {
...currentGameConfig,
matching: {
...currentMatchingConfig,
gameType,
},
}
console.log(
'[RoomMemoryPairsProvider] Saving gameType to database:',
JSON.stringify(
{
roomId: roomData.id,
updatedConfig,
},
null,
2
)
)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryPairsProvider] Cannot save gameType - no roomData.id')
}
},
[activePlayers, sendMove]
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
const setDifficulty = useCallback(
(difficulty: typeof state.difficulty) => {
console.log('[RoomMemoryPairsProvider] setDifficulty called:', difficulty)
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
userId: viewerId || '',
data: { field: 'difficulty', value: difficulty },
})
// Save setting to room's gameConfig for persistence
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
const updatedConfig = {
...currentGameConfig,
matching: {
...currentMatchingConfig,
difficulty,
},
}
console.log(
'[RoomMemoryPairsProvider] Saving difficulty to database:',
JSON.stringify(
{
roomId: roomData.id,
updatedConfig,
},
null,
2
)
)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryPairsProvider] Cannot save difficulty - no roomData.id')
}
},
[activePlayers, sendMove]
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
const setTurnTimer = useCallback(
(turnTimer: typeof state.turnTimer) => {
console.log('[RoomMemoryPairsProvider] setTurnTimer called:', turnTimer)
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
userId: viewerId || '',
data: { field: 'turnTimer', value: turnTimer },
})
// Save setting to room's gameConfig for persistence
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
const updatedConfig = {
...currentGameConfig,
matching: {
...currentMatchingConfig,
turnTimer,
},
}
console.log(
'[RoomMemoryPairsProvider] Saving turnTimer to database:',
JSON.stringify(
{
roomId: roomData.id,
updatedConfig,
},
null,
2
)
)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryPairsProvider] Cannot save turnTimer - no roomData.id')
}
},
[activePlayers, sendMove]
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
const goToSetup = useCallback(() => {
@@ -517,9 +711,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 +727,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 +742,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 +754,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,24 +1,14 @@
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'
)
// Use a ref to track the current input to avoid stale reads during rapid typing
// This prevents the "type 8 times" issue where state.currentInput hasn't updated yet
const currentInputRef = useRef(state.currentInput)
// Keep ref in sync with state
useEffect(() => {
currentInputRef.current = state.currentInput
}, [state.currentInput])
// Use keyboard state from parent state instead of local state
const { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard } = state
@@ -116,8 +106,7 @@ export function InputPhase() {
const acceptCorrectNumber = useCallback(
(number: number) => {
acceptNumber?.(number)
currentInputRef.current = '' // Clear ref immediately
setInput?.('')
// setInput('') is called inside acceptNumber action creator
setDisplayFeedback('correct')
setTimeout(() => setDisplayFeedback('neutral'), 500)
@@ -127,11 +116,11 @@ 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(() => {
const wrongNumber = parseInt(currentInputRef.current, 10)
const wrongNumber = parseInt(state.currentInput, 10)
if (!Number.isNaN(wrongNumber)) {
dispatch({ type: 'ADD_WRONG_GUESS_ANIMATION', number: wrongNumber })
// Clear wrong guess animations after explosion
@@ -141,8 +130,7 @@ export function InputPhase() {
}
rejectNumber?.()
currentInputRef.current = '' // Clear ref immediately
setInput?.('')
// setInput('') is called inside rejectNumber action creator
setDisplayFeedback('incorrect')
setTimeout(() => setDisplayFeedback('neutral'), 500)
@@ -151,7 +139,7 @@ export function InputPhase() {
if (state.guessesRemaining - 1 === 0) {
setTimeout(() => showResults?.(), 1000)
}
}, [dispatch, rejectNumber, setInput, showResults, state.guessesRemaining])
}, [state.currentInput, dispatch, rejectNumber, showResults, state.guessesRemaining])
// Simple keyboard event handlers that will be defined after callbacks
const handleKeyboardInput = useCallback(
@@ -161,9 +149,8 @@ export function InputPhase() {
// Only handle if input phase is active and guesses remain
if (state.guessesRemaining === 0) return
// Use ref for immediate value, update ref right away to prevent stale reads
const newInput = currentInputRef.current + key
currentInputRef.current = newInput // Update ref immediately for next keypress
// Update input with new key
const newInput = state.currentInput + key
setInput?.(newInput)
// Clear any existing timeout
@@ -215,9 +202,8 @@ export function InputPhase() {
)
const handleKeyboardBackspace = useCallback(() => {
if (currentInputRef.current.length > 0) {
const newInput = currentInputRef.current.slice(0, -1)
currentInputRef.current = newInput // Update ref immediately
if (state.currentInput.length > 0) {
const newInput = state.currentInput.slice(0, -1)
setInput?.(newInput)
// Clear any existing timeout
@@ -228,7 +214,7 @@ export function InputPhase() {
setDisplayFeedback('neutral')
}
}, [state.prefixAcceptanceTimeout, dispatch, setInput])
}, [state.currentInput, state.prefixAcceptanceTimeout, dispatch, setInput])
// Set up global keyboard listeners
useEffect(() => {
@@ -383,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, 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'
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,25 @@ 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 || {}
// Extract unique userIds from playerMetadata
const uniqueUserIds = new Set<string>()
for (const playerId of activePlayers) {
const metadata = playerMetadata[playerId]
if (metadata?.userId) {
uniqueUserIds.add(metadata.userId)
}
}
// Initialize scores for each userId
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
}, {})
return {
...state,
quizCards,
@@ -57,6 +82,10 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
// Multiplayer state
activePlayers,
playerMetadata,
playerScores,
}
}
@@ -72,26 +101,53 @@ 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 || {}
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
}
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 || {}
const newPlayerScores = { ...playerScores }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
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 +173,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 +196,74 @@ 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()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active player IDs as array
const activePlayers = Array.from(activePlayerIds)
// LOCAL-ONLY state for current input (not synced over network)
// This prevents sending a network request for every keystroke
const [localCurrentInput, setLocalCurrentInput] = useState('')
// Merge saved game config from room with initialState
// Settings are scoped by game name to preserve settings when switching games
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
console.log(
'[RoomMemoryQuizProvider] Initializing - Full roomData.gameConfig:',
JSON.stringify(gameConfig, null, 2)
)
if (!gameConfig) {
console.log(
'[RoomMemoryQuizProvider] No gameConfig, using initialState with playMode:',
initialState.playMode
)
return initialState
}
// Get settings for this specific game (memory-quiz)
const savedConfig = gameConfig['memory-quiz'] as Record<string, any> | null | undefined
console.log(
'[RoomMemoryQuizProvider] Extracted memory-quiz config:',
JSON.stringify(savedConfig, null, 2)
)
console.log('[RoomMemoryQuizProvider] savedConfig.playMode value:', savedConfig?.playMode)
if (!savedConfig) {
console.log(
'[RoomMemoryQuizProvider] No saved config for memory-quiz, using initialState with playMode:',
initialState.playMode
)
return initialState
}
const merged = {
...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,
}
console.log(
'[RoomMemoryQuizProvider] Merged state:',
JSON.stringify(
{
selectedCount: merged.selectedCount,
displayTime: merged.displayTime,
selectedDifficulty: merged.selectedDifficulty,
playMode: merged.playMode,
},
null,
2
)
)
console.log('[RoomMemoryQuizProvider] Final merged.playMode:', merged.playMode)
return merged
}, [roomData?.gameConfig])
// Arcade session integration WITH room sync
const {
@@ -150,10 +274,17 @@ 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,
})
// 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 +294,53 @@ 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(() => {
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
const metadata = buildPlayerMetadataUtil(activePlayers, playerOwnership, players, viewerId)
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()
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 +348,21 @@ 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('')
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 +370,28 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
)
const rejectNumber = useCallback(() => {
// Clear local input immediately
setLocalCurrentInput('')
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 +399,158 @@ 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) => {
console.log(`[RoomMemoryQuizProvider] setConfig called: ${field} = ${value}`)
sendMove({
type: 'SET_CONFIG',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { field, value },
})
// Save setting to room's gameConfig for persistence
// Settings are scoped by game name to preserve settings when switching games
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
console.log('[RoomMemoryQuizProvider] Current gameConfig:', currentGameConfig)
const currentMemoryQuizConfig =
(currentGameConfig['memory-quiz'] as Record<string, any>) || {}
console.log('[RoomMemoryQuizProvider] Current memory-quiz config:', currentMemoryQuizConfig)
const updatedConfig = {
...currentGameConfig,
'memory-quiz': {
...currentMemoryQuizConfig,
[field]: value,
},
}
console.log('[RoomMemoryQuizProvider] Saving updated gameConfig:', updatedConfig)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryQuizProvider] No roomData.id, cannot save config')
}
},
[viewerId, sendMove]
[viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
// 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 +558,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

@@ -111,13 +111,13 @@ export default function RoomPage() {
console.log('[RoomPage] Calling setRoomGame with:', {
roomId: roomData.id,
gameName: internalGameName,
gameConfig: {},
preservingGameConfig: true,
})
// Don't pass gameConfig - we want to preserve existing settings for all games
setRoomGame({
roomId: roomData.id,
gameName: internalGameName,
gameConfig: {},
})
}

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

@@ -23,6 +23,7 @@ export interface RoomData {
name: string
code: string
gameName: string | null // Nullable to support game selection in room
gameConfig?: Record<string, unknown> | null // Game-specific settings
accessMode: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
members: RoomMember[]
memberPlayers: Record<string, RoomPlayer[]> // userId -> players
@@ -71,6 +72,7 @@ async function fetchCurrentRoom(): Promise<RoomData | null> {
name: data.room.name,
code: data.room.code,
gameName: data.room.gameName,
gameConfig: data.room.gameConfig || null,
accessMode: data.room.accessMode || 'open',
members: data.members || [],
memberPlayers: data.memberPlayers || {},
@@ -105,6 +107,7 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
name: data.room.name,
code: data.room.code,
gameName: data.room.gameName,
gameConfig: data.room.gameConfig || null,
accessMode: data.room.accessMode || 'open',
members: data.members || [],
memberPlayers: data.memberPlayers || {},
@@ -141,6 +144,7 @@ async function joinRoomApi(params: {
name: data.room.name,
code: data.room.code,
gameName: data.room.gameName,
gameConfig: data.room.gameConfig || null,
accessMode: data.room.accessMode || 'open',
members: data.members || [],
memberPlayers: data.memberPlayers || {},
@@ -183,6 +187,7 @@ async function getRoomByCodeApi(code: string): Promise<RoomData> {
name: data.room.name,
code: data.room.code,
gameName: data.room.gameName,
gameConfig: data.room.gameConfig || null,
accessMode: data.room.accessMode || 'open',
members: data.members || [],
memberPlayers: data.memberPlayers || {},
@@ -348,7 +353,6 @@ export function useRoomData() {
// Moderation event handlers
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string; reason?: string }) => {
console.log('[useRoomData] User was kicked from room:', data)
setModerationEvent({
type: 'kicked',
data: {
@@ -362,7 +366,6 @@ export function useRoomData() {
}
const handleBannedFromRoom = (data: { roomId: string; bannedBy: string; reason: string }) => {
console.log('[useRoomData] User was banned from room:', data)
setModerationEvent({
type: 'banned',
data: {
@@ -386,7 +389,6 @@ export function useRoomData() {
createdAt: Date
}
}) => {
console.log('[useRoomData] New report submitted:', data)
setModerationEvent({
type: 'report',
data: {
@@ -411,7 +413,6 @@ export function useRoomData() {
createdAt: Date
}
}) => {
console.log('[useRoomData] Room invitation received:', data)
setModerationEvent({
type: 'invitation',
data: {
@@ -434,7 +435,6 @@ export function useRoomData() {
createdAt: Date
}
}) => {
console.log('[useRoomData] New join request submitted:', data)
setModerationEvent({
type: 'join-request',
data: {
@@ -446,6 +446,25 @@ 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,
// Only update gameConfig if it was provided in the broadcast
...(data.gameConfig !== undefined ? { gameConfig: data.gameConfig } : {}),
}
})
}
}
socket.on('room-joined', handleRoomJoined)
socket.on('member-joined', handleMemberJoined)
socket.on('member-left', handleMemberLeft)
@@ -455,6 +474,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,13 +486,13 @@ 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])
// Function to notify room members of player updates
const notifyRoomOfPlayerUpdate = useCallback(() => {
if (socket && roomData?.id && userId) {
console.log('[useRoomData] Notifying room of player update')
socket.emit('players-updated', { roomId: roomData.id, userId })
}
}, [socket, roomData?.id, userId])
@@ -567,13 +587,20 @@ async function setRoomGameApi(params: {
gameName: string
gameConfig?: Record<string, unknown>
}): Promise<void> {
// Only include gameConfig in the request if it was explicitly provided
// Otherwise, we preserve the existing gameConfig in the database
const body: { gameName: string; gameConfig?: Record<string, unknown> } = {
gameName: params.gameName,
}
if (params.gameConfig !== undefined) {
body.gameConfig = params.gameConfig
}
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gameName: params.gameName,
gameConfig: params.gameConfig || {},
}),
body: JSON.stringify(body),
})
if (!response.ok) {
@@ -607,6 +634,8 @@ export function useSetRoomGame() {
/**
* Clear/reset game for a room (host only)
* This only clears gameName (returns to game selection) but preserves gameConfig
* so settings persist when the user selects a game again.
*/
async function clearRoomGameApi(roomId: string): Promise<void> {
const response = await fetch(`/api/arcade/rooms/${roomId}/settings`, {
@@ -614,7 +643,7 @@ async function clearRoomGameApi(roomId: string): Promise<void> {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gameName: null,
gameConfig: null,
// DO NOT send gameConfig: null - we want to preserve settings!
}),
})
@@ -646,3 +675,65 @@ export function useClearRoomGame() {
},
})
}
/**
* Update game config for current room (game-specific settings)
*/
async function updateGameConfigApi(params: {
roomId: string
gameConfig: Record<string, unknown>
}): Promise<void> {
console.log(
'[updateGameConfigApi] Sending PATCH to server:',
JSON.stringify(
{
url: `/api/arcade/rooms/${params.roomId}/settings`,
gameConfig: params.gameConfig,
},
null,
2
)
)
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()
console.error('[updateGameConfigApi] Server error:', JSON.stringify(errorData, null, 2))
throw new Error(errorData.error || 'Failed to update game config')
}
console.log('[updateGameConfigApi] Server responded OK')
}
/**
* Hook: Update game config for current room
* This allows games to persist their settings (e.g., difficulty, card count)
*/
export function useUpdateGameConfig() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateGameConfigApi,
onSuccess: (_, variables) => {
// Update the cache with the new gameConfig
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
if (!prev) return null
return {
...prev,
gameConfig: variables.gameConfig,
}
})
console.log(
'[useUpdateGameConfig] Updated cache with new gameConfig:',
JSON.stringify(variables.gameConfig, null, 2)
)
},
})
}

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}` }
}
@@ -342,8 +405,14 @@ export class MemoryQuizGameValidator
selectedCount: number
displayTime: number
selectedDifficulty: DifficultyLevel
playMode?: 'cooperative' | 'competitive'
}): SorobanQuizState {
return {
console.log(
'[MemoryQuizValidator] getInitialState called with config:',
JSON.stringify(config, null, 2)
)
const initialState: SorobanQuizState = {
cards: [],
quizCards: [],
correctAnswers: [],
@@ -355,6 +424,13 @@ export class MemoryQuizGameValidator
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
// Multiplayer state
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: config.playMode || 'cooperative',
numberFoundBy: {},
// UI state
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
@@ -363,6 +439,9 @@ export class MemoryQuizGameValidator
testingMode: false,
showOnScreenKeyboard: false,
}
console.log('[MemoryQuizValidator] getInitialState returning playMode:', initialState.playMode)
return initialState
}
}

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

@@ -84,17 +84,45 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Different games have different initial configs
let initialState: any
if (room.gameName === 'matching') {
// Access nested gameConfig: { matching: { gameType, difficulty, turnTimer } }
const matchingConfig = (room.gameConfig as any)?.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,
difficulty: matchingConfig.difficulty || 6,
gameType: matchingConfig.gameType || 'abacus-numeral',
turnTimer: matchingConfig.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',
})
// Access nested gameConfig: { 'memory-quiz': { selectedCount, displayTime, selectedDifficulty, playMode } }
const memoryQuizConfig = (room.gameConfig as any)?.['memory-quiz'] || {}
console.log(
'[join-arcade-session] memory-quiz - Full room.gameConfig:',
JSON.stringify(room.gameConfig, null, 2)
)
console.log(
'[join-arcade-session] memory-quiz - Extracted memoryQuizConfig:',
JSON.stringify(memoryQuizConfig, null, 2)
)
console.log(
'[join-arcade-session] memory-quiz - playMode from config:',
memoryQuizConfig.playMode
)
const configToPass = {
selectedCount: memoryQuizConfig.selectedCount || 5,
displayTime: memoryQuizConfig.displayTime || 2.0,
selectedDifficulty: memoryQuizConfig.selectedDifficulty || 'easy',
playMode: memoryQuizConfig.playMode || 'cooperative',
}
console.log(
'[join-arcade-session] memory-quiz - Config being passed to getInitialState:',
JSON.stringify(configToPass, null, 2)
)
initialState = validator.getInitialState(configToPass)
console.log(
'[join-arcade-session] memory-quiz - initialState.playMode after getInitialState:',
initialState.playMode
)
} else {
// Fallback for other games
initialState = validator.getInitialState(room.gameConfig || {})
@@ -124,7 +152,17 @@ export function initializeSocketServer(httpServer: HTTPServer) {
roomId,
version: session.version,
sessionUserId: session.userId,
gameName: session.currentGame,
})
// Log playMode specifically for memory-quiz
if (session.currentGame === 'memory-quiz') {
console.log(
'[join-arcade-session] memory-quiz session - gameState.playMode:',
(session.gameState as any).playMode
)
}
socket.emit('session-state', {
gameState: session.gameState,
currentGame: session.currentGame,

View File

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