Compare commits

...

104 Commits

Author SHA1 Message Date
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
semantic-release-bot
297927401c chore(release): 3.14.4 [skip ci]
## [3.14.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.3...v3.14.4) (2025-10-15)

### Bug Fixes

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 19:30:12 -05:00
semantic-release-bot
a57ebdf142 chore(release): 3.14.3 [skip ci]
## [3.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.2...v3.14.3) (2025-10-15)

### Bug Fixes

* **arcade:** delete old session when room game changes ([98a3a25](98a3a2573d))
2025-10-15 00:17:53 +00:00
Thomas Hallock
98a3a2573d fix(arcade): delete old session when room game changes
When changing a room's game via the settings API, the old arcade
session was persisting with the previous game's state. This caused
users to still see the old game after selecting a new one.

Changes:
- Delete existing arcade session when gameName is updated in room settings
- Add debug logging to room page game selection handler
- Ensure fresh session is created with new game settings

This fixes the issue where clicking Memory Lightning would not
properly switch the game from Battle Arena.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 19:17:02 -05:00
semantic-release-bot
0fd680396c chore(release): 3.14.2 [skip ci]
## [3.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.1...v3.14.2) (2025-10-15)

### Bug Fixes

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 19:06:00 -05:00
semantic-release-bot
f37733bff6 chore(release): 3.14.1 [skip ci]
## [3.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.0...v3.14.1) (2025-10-14)

### Bug Fixes

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 18:28:01 -05:00
semantic-release-bot
d8b5201af9 chore(release): 3.14.0 [skip ci]
## [3.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.7...v3.14.0) (2025-10-14)

### Features

* **arcade:** add Change Game functionality for room hosts ([ee39241](ee39241e3c))
* **arcade:** add game selection screen with navigation to room page ([4124f1c](4124f1cc08))

### Bug Fixes

* **player-config:** correct label positioning in player settings dialog ([554cc40](554cc4063b))

### Code Refactoring

* implement in-room game selection UI ([f07b96d](f07b96d26e))
* make game_name nullable to support in-room game selection ([a9a6cef](a9a6cefafc))
* **nav:** rename emphasizeGameContext to emphasizePlayerSelection ([6bb7016](6bb7016eea))
2025-10-14 17:31:58 +00:00
Thomas Hallock
554cc4063b fix(player-config): correct label positioning in player settings dialog
Reorganizes layout so labels appear under their corresponding elements:
- Character count under name input
- "Random name" under dice button

Previously labels were misaligned and confusing.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:45 -05:00
Thomas Hallock
6bb7016eea refactor(nav): rename emphasizeGameContext to emphasizePlayerSelection
Improves clarity by renaming the prop to better describe its purpose:
highlighting the player selection/roster UI in the navigation bar.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:38 -05:00
Thomas Hallock
4124f1cc08 feat(arcade): add game selection screen with navigation to room page
- Wraps game selection in PageWithNav for consistent navigation
- Adds game type mapping (GameType keys to internal game names)
- Enables player selection mode on game selection screen
- Adds navigation to "unsupported game" screen
- Fixes 400 error when selecting games like "Matching Pairs Battle"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:30 -05:00
Thomas Hallock
ee39241e3c feat(arcade): add Change Game functionality for room hosts
Allows room hosts to return to game selection screen by clearing the
room's game selection. Adds useClearRoomGame hook and "Change Game"
menu item in room dropdown (only visible when a game is selected).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:22 -05:00
Thomas Hallock
f07b96d26e refactor: implement in-room game selection UI
Phase 2: UI and workflow updates

- Update room settings API to support setting game via PATCH
- Add useSetRoomGame hook for client-side game selection
- Update /arcade/room page to show game selection when no game set
- Create beautiful game selection UI with gradient cards
- Update AddPlayerButton to create rooms without games
- Navigate to /arcade/room after creating or joining rooms
- Remove dependency on local-only play - all games now room-based

Workflow:
1. User clicks "Create Room" from (+) menu
2. Room is created without a game (gameName = null)
3. User is navigated to /arcade/room
4. Game selection screen is shown
5. User clicks a game
6. Room game is set via API
7. Game loads - URL never changes, it's always /arcade/room

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 11:33:39 -05:00
Thomas Hallock
a9a6cefafc refactor: make game_name nullable to support in-room game selection
Phase 1: Database and API updates

- Create migration 0010 to make game_name and game_config nullable
- Update arcade_rooms schema to support rooms without games
- Update RoomData interface to make gameName optional
- Update CreateRoomParams to make gameName optional
- Update room creation API to allow null gameName
- Update all room data parsing to handle null gameName

This allows rooms to be created without a game selected, enabling
users to choose a game inside the room itself. The URL remains
/arcade/room regardless of selection, setup, or gameplay state.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 11:30:27 -05:00
Thomas Hallock
710e93c997 revert(nav): restore original room creation/join behavior
Reverts navigation changes that broke lifted state popover behavior.

Original behavior (now restored):
- Create room: Keep popover open, switch to invite tab to share code
- Join room: Close popover, stay on current page

The navigation changes caused the popover to close immediately,
breaking the lifted state pattern that was intentionally designed
to keep the popover open for sharing room codes after creation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 11:11:03 -05:00
semantic-release-bot
b419e5e3ad chore(release): 3.13.7 [skip ci]
## [3.13.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.6...v3.13.7) (2025-10-14)

### Bug Fixes

* **toast:** scope animations to prevent affecting other UI elements ([245ed8a](245ed8a625))
2025-10-14 16:02:24 +00:00
Thomas Hallock
245ed8a625 fix(toast): scope animations to prevent affecting other UI elements
The toast CSS animations were using overly broad selectors like
[data-state='open'] which affected ANY element with data-state
attributes, causing nav items and other components to trigger the
toast slide-in/slide-out animations on hover.

Fixed by:
- Renaming animations: slideIn → toastSlideIn, etc.
- Scoping selectors: [data-radix-toast-viewport] [data-state='open']
- Now only toast elements within the viewport are animated

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 11:01:28 -05:00
semantic-release-bot
2b68ddc732 chore(release): 3.13.6 [skip ci]
## [3.13.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.5...v3.13.6) (2025-10-14)

### Bug Fixes

* **nav:** navigate to /arcade/room (not /arcade/rooms/{id}) ([1c55f36](1c55f3630c))
2025-10-14 15:40:44 +00:00
Thomas Hallock
1c55f3630c fix(nav): navigate to /arcade/room (not /arcade/rooms/{id})
Rooms are modal and use a single route /arcade/room that fetches
the user's current room. Fixed navigation for both:
- Creating a new room
- Joining an existing room

Both now navigate to /arcade/room instead of /arcade/rooms/{id}

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:39:50 -05:00
semantic-release-bot
1e34d57ad6 chore(release): 3.13.5 [skip ci]
## [3.13.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.4...v3.13.5) (2025-10-14)

### Bug Fixes

* **nav:** navigate to room after creation from (+) menu ([21e6e33](21e6e33173))

### Documentation

* add production deployment guide ([6d16436](6d16436133))
2025-10-14 15:29:11 +00:00
Thomas Hallock
21e6e33173 fix(nav): navigate to room after creation from (+) menu
When creating a room from the /arcade page using the (+) button:
- Add room to recent rooms list
- Close the popover
- Navigate to the room page immediately

This fixes the UX issue where users would create a room but
remain on the /arcade page without any clear indication of
how to access their new room.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:28:14 -05:00
Thomas Hallock
6d16436133 docs: add production deployment guide
Add comprehensive deployment documentation including:
- Production server infrastructure details
- Docker configuration and paths
- Database management and migration procedures
- CI/CD pipeline explanation
- Manual deployment procedures
- Troubleshooting guide

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:26:30 -05:00
semantic-release-bot
6b489238c8 chore(release): 3.13.4 [skip ci]
## [3.13.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.3...v3.13.4) (2025-10-14)

### Bug Fixes

* **api:** include members and memberPlayers in room creation response ([8320d9e](8320d9e730))
2025-10-14 15:14:53 +00:00
Thomas Hallock
8320d9e730 fix(api): include members and memberPlayers in room creation response
The client expects the POST /api/arcade/rooms response to include
members and memberPlayers fields, but the API was only returning
room and joinUrl. This caused room creation to fail on the client.

Fixes the "failed to create room" error on production.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:13:57 -05:00
semantic-release-bot
a4251e660d chore(release): 3.13.3 [skip ci]
## [3.13.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.2...v3.13.3) (2025-10-14)

### Bug Fixes

* **migrations:** add migration 0009 for display_password column ([040d749](040d7495a0))

### Code Refactoring

* replace browser alert() calls with toast notifications ([87ef356](87ef35682e))
2025-10-14 14:57:31 +00:00
Thomas Hallock
040d7495a0 fix(migrations): add migration 0009 for display_password column
- Create 0009_add_display_password.sql migration
- Add entry to drizzle journal
- This adds the display_password column that was missing in production

The plan is to nuke the production database and let all migrations
run from scratch on container restart.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 09:56:26 -05:00
Thomas Hallock
87ef35682e refactor: replace browser alert() calls with toast notifications
- Create ToastContext with useToast hook for app-wide toast management
- Add ToastProvider to ClientProviders for global toast access
- Replace all 13 alert() calls across arcade room pages and components
- Use consistent toast patterns: showError, showSuccess, showInfo
- Improve UX with dismissible, auto-timing toast notifications

Files updated:
- src/components/common/ToastContext.tsx (new)
- src/components/ClientProviders.tsx
- src/app/arcade-rooms/page.tsx
- src/app/arcade-rooms/[roomId]/page.tsx
- src/components/nav/ModerationNotifications.tsx
- src/components/nav/AddPlayerButton.tsx
- src/components/nav/PendingInvitations.tsx

Also removed invalid manually-created migration 0009 (will be regenerated properly)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 09:08:41 -05:00
semantic-release-bot
2fb6ead4f2 chore(release): 3.13.2 [skip ci]
## [3.13.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.1...v3.13.2) (2025-10-14)

### Bug Fixes

* **arcade:** only notify room creator of join requests ([bc571e3](bc571e3d0d))
2025-10-14 13:55:57 +00:00
Thomas Hallock
bc571e3d0d fix(arcade): only notify room creator of join requests
Fixes issue where ALL room members were seeing join request approval
toasts, but only the creator can approve them, leading to confusing
error messages when non-creators clicked approve/deny.

Changes:
- Join request notifications now sent only to room creator's user channel
- Changed from broadcasting to entire room to targeted user notification
- Uses `user:${room.createdBy}` channel instead of `room:${roomId}`

Non-host users will no longer see approval toasts they cannot act on.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:55:10 -05:00
semantic-release-bot
eed7c9b938 chore(release): 3.13.1 [skip ci]
## [3.13.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.0...v3.13.1) (2025-10-14)

### Bug Fixes

* **arcade:** allow room creator to rejoin restricted/approval rooms ([654ba19](654ba19ccc))
2025-10-14 13:54:31 +00:00
Thomas Hallock
654ba19ccc fix(arcade): allow room creator to rejoin restricted/approval rooms
Fixes catch-22 where room creator who leaves their own approval-only
or restricted room cannot rejoin because:
- Approval-only: They need approval but can't approve themselves
- Restricted: They need an invitation but can't invite themselves

Changes:
- Room creator now bypasses invitation check for restricted rooms
- Room creator now bypasses approval check for approval-only rooms
- Other users still require proper authorization

This ensures hosts can always access their own rooms regardless of
access mode restrictions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:53:39 -05:00
semantic-release-bot
f5469cda0c chore(release): 3.13.0 [skip ci]
## [3.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.12.0...v3.13.0) (2025-10-14)

### Features

* **moderation:** add inline feedback and persistent password display ([86e3d41](86e3d41996))
2025-10-14 13:53:19 +00:00
Thomas Hallock
86e3d41996 feat(moderation): add inline feedback and persistent password display
- Add success/error message UI component to ModerationPanel
- Replace all browser alert() calls with inline React-based feedback
- Add displayPassword field to arcade_rooms schema for plain text storage
- Create migration to add display_password column
- Update settings PATCH route to store both hashed and display passwords
- Update room GET route to return displayPassword only to room creator
- Update ModerationPanel to populate password field when loading settings
- Fix room-manager test to include displayPassword field

Password field now persists and displays correctly when reloading the page
for room owners in password-protected rooms.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:52:19 -05:00
semantic-release-bot
cb11bec975 chore(release): 3.12.0 [skip ci]
## [3.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.1...v3.12.0) (2025-10-14)

### Features

* **moderation:** improve password input with copy button ([2580e47](2580e474d0))
2025-10-14 13:40:37 +00:00
Thomas Hallock
2580e474d0 feat(moderation): improve password input with copy button
Enhances the password-protected room settings UX:

Changes:
1. Password input now stays visible and editable
   - Plain text input (not hidden) for easy viewing
   - Focus state with orange border
   - Clear placeholder text

2. Copy button next to password input
   - 📋 Copy icon with text
   - Visual feedback: changes to "✓ Copied!" for 2 seconds
   - Disabled state when no password entered
   - Green success color after copying

3. Better labeling and hints
   - "Room Password" label above input
   - Helper text: "Share this password with guests to allow them to join"
   - More descriptive placeholder

Note: Passwords are hashed in the database for security, so existing
passwords cannot be retrieved. This UI is for setting/changing passwords
and easily copying them to share with guests.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:39:42 -05:00
semantic-release-bot
55e0be8e42 chore(release): 3.11.1 [skip ci]
## [3.11.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.0...v3.11.1) (2025-10-14)

### Bug Fixes

* **moderation:** improve access mode settings UX ([dd9e657](dd9e657db8))
2025-10-14 13:36:29 +00:00
Thomas Hallock
dd9e657db8 fix(moderation): improve access mode settings UX
Enhances the room moderation settings UX to prevent accidental closure
with unsaved changes:

Changes:
1. "Update Access Mode" button now only appears when there are changes
   - Tracks original access mode on load
   - Compares current selection to detect changes
   - Button hidden when no changes made

2. "Close" button disabled when there are unsaved access mode changes
   - Prevents accidentally losing changes
   - Visual feedback: dimmed appearance, orange border
   - Hover state brightens orange border to draw attention

3. Tooltip on disabled "Close" button
   - Shows "Please update access mode settings before closing"
   - Helps users understand why close is disabled

This prevents the frustrating UX issue where users want to close but
are forced to update settings they didn't intend to change.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:35:36 -05:00
semantic-release-bot
51d9a37f9b chore(release): 3.11.0 [skip ci]
## [3.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.10.0...v3.11.0) (2025-10-14)

### Features

* add name generator button and abacus emoji ([07212e4](07212e4df0))

### Code Refactoring

* make player names abacus and arithmetic themed ([97daad9](97daad9abb))
2025-10-14 13:31:03 +00:00
Thomas Hallock
07212e4df0 feat: add name generator button and abacus emoji
Adds two enhancements to player customization:

1. Name generator button in PlayerConfigDialog
   - Dice emoji (🎲) button next to name input
   - Generates new themed names on click
   - Excludes current player from collision check
   - Maintains auto-save behavior

2. Abacus emoji option
   - Added 🧮 (abacus) as first emoji choice
   - Perfect thematic fit for the application

Now players can easily try different generated names without leaving
the settings dialog.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:30:14 -05:00
Thomas Hallock
97daad9abb refactor: make player names abacus and arithmetic themed
Replaces generic fantasy/gaming words with abacus and math-themed vocabulary.

Examples of new names:
- "Ancient Abacist", "Sliding Counter", "Soroban Master"
- "Calculating Mathematician", "Rapid Solver", "Precise Adder"
- "Bamboo Scholar", "Golden Merchant", "Mental Genius"

Changes:
- 25 abacus-specific adjectives (Ancient, Wooden, Soroban, etc.)
- 25 arithmetic adjectives (Adding, Calculating, Prime, etc.)
- 25 abacus-specific nouns (Counter, Abacist, Bead, Rod, etc.)
- 25 arithmetic nouns (Mathematician, Solver, Adder, etc.)
- Still maintains 2,500 unique combinations
- All tests pass with new vocabulary

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:29:13 -05:00
semantic-release-bot
225104c3a7 chore(release): 3.10.0 [skip ci]
## [3.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.2...v3.10.0) (2025-10-14)

### Features

* add fun automatic player naming system ([249257c](249257c6c7))
2025-10-14 13:26:03 +00:00
Thomas Hallock
249257c6c7 feat: add fun automatic player naming system
Implements automatic generation of creative player names combining
adjectives with nouns/roles (e.g., "Swift Ninja", "Cosmic Wizard").

Changes:
- Created playerNames utility with 50 adjectives and 50 nouns
- Generates unique names with collision detection
- Applied to default player creation and addPlayer function
- Replaces generic "Player 1", "Player 2" with fun names
- Manual override still available via PlayerConfigDialog
- Added comprehensive unit tests (10 passing tests)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:25:05 -05:00
semantic-release-bot
b37e29e53e chore(release): 3.9.2 [skip ci]
## [3.9.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.1...v3.9.2) (2025-10-14)

### Bug Fixes

* remove duplicate ModerationNotifications causing double toasts ([c6886a0](c6886a0e59))
2025-10-14 13:19:07 +00:00
Thomas Hallock
c6886a0e59 fix: remove duplicate ModerationNotifications causing double toasts
**Root Cause:**
- ModerationNotifications was rendered in BOTH /arcade/room/page.tsx AND PageWithNav
- Both had separate useRoomData hooks, creating two socket listeners
- When join-request-submitted event fired, BOTH instances showed a toast
- Clicking "Approve" only closed one toast, leaving the other visible

**Fix:**
- Removed ModerationNotifications from room page entirely
- PageWithNav (inside MemoryPairsGame) already handles all moderation events
- Now only ONE instance listens and renders, so approvals properly dismiss

**Files Changed:**
- Removed ModerationNotifications import from room page
- Removed all 4 instances of <ModerationNotifications /> from room page
- Removed moderationEvent and clearModerationEvent from useRoomData destructuring
- Added comment explaining PageWithNav handles notifications

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:18:12 -05:00
semantic-release-bot
cb2fec1da5 chore(release): 3.9.1 [skip ci]
## [3.9.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.0...v3.9.1) (2025-10-14)

### Bug Fixes

* reset join request toast state when moderation event cleared ([6beb58a](6beb58a7b8))
2025-10-14 13:14:31 +00:00
Thomas Hallock
6beb58a7b8 fix: reset join request toast state when moderation event cleared
**Issue:**
Toast notification was persisting after successful approval because
showJoinRequestToast state was never reset to false.

**Fix:**
- Add else clause to useEffect that resets toast state when event cleared
- Reset both showJoinRequestToast and requestError when event changes type
- Ensures toast properly dismisses after approval/denial

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:13:40 -05:00
semantic-release-bot
544b06e290 chore(release): 3.9.0 [skip ci]
## [3.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.1...v3.9.0) (2025-10-14)

### Features

* prevent invitations to retired rooms ([a7c3c1f](a7c3c1f4cd))
2025-10-14 13:13:04 +00:00
Thomas Hallock
a7c3c1f4cd feat: prevent invitations to retired rooms
- Add room access mode check in invite POST endpoint
- Block invitation creation if room is retired (403 status)
- Clear error message: "Cannot send invitations to retired rooms"
- Check happens before host validation to catch retired rooms early

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:12:10 -05:00
semantic-release-bot
090d4dac2b chore(release): 3.8.1 [skip ci]
## [3.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.0...v3.8.1) (2025-10-14)

### Bug Fixes

* improve kicked modal message for retired room ejections ([f865ce1](f865ce16ec))
2025-10-14 13:10:33 +00:00
Thomas Hallock
f865ce16ec fix: improve kicked modal message for retired room ejections
**Socket handler update:**
- Capture reason field from kicked-from-room socket event

**Modal UI improvements:**
- Detect when reason contains "retired"
- Show 🏁 emoji instead of ⚠️ for retired rooms
- Title changes from "Kicked from Room" to "Room Retired"
- Message explains room owner retired the room and access is closed
- Clarify only room owner can access retired rooms

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:09:41 -05:00
semantic-release-bot
50f45ab08e chore(release): 3.8.0 [skip ci]
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.0) (2025-10-14)

### Features

* implement proper retired room behavior with member expulsion ([a2d5368](a2d53680f2))
2025-10-14 13:08:48 +00:00
Thomas Hallock
a2d53680f2 feat: implement proper retired room behavior with member expulsion
**Join endpoint changes:**
- Only room creator can access retired rooms
- All other users blocked with 410 status and clear message

**Settings endpoint changes:**
- When room set to 'retired', all non-owner members immediately expelled
- Expelled members removed from database
- Each expulsion recorded in member history
- Socket notifications sent to all expelled members (kicked-from-room event)
- Owner notified of member expulsions via member-left event

**Member expulsion flow:**
- Similar pattern to ban ejection
- Expelled members see "kicked from room" modal with reason "Room has been retired"
- All expelled members logged in history with 'left' action

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:07:49 -05:00
semantic-release-bot
b9e7267f15 chore(release): 3.7.1 [skip ci]
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)

### Bug Fixes

* improve join request approval error handling with actionable messages ([57bf846](57bf8460c8))
2025-10-14 13:03:49 +00:00
Thomas Hallock
57bf8460c8 fix: improve join request approval error handling with actionable messages
- Add requestError state to track approval/deny errors
- Toast dismisses only on successful approval/deny
- On error, toast remains visible and displays error message inline
- Parse API error response to show meaningful error messages
- User can retry approval/deny action after error
- Replace generic alert() with styled error message within toast

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:02:56 -05:00
semantic-release-bot
059a9fe750 chore(release): 3.7.0 [skip ci]
## [3.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.3...v3.7.0) (2025-10-14)

### Features

* add prominent join request approval notifications for room moderators ([036da6d](036da6de66))
2025-10-14 13:00:09 +00:00
Thomas Hallock
036da6de66 feat: add prominent join request approval notifications for room moderators
- Add 'join-request' type to ModerationEvent interface
- Add socket listener for 'join-request-submitted' event in useRoomData
- Update join request POST endpoint to broadcast socket event to room members
- Add prominent toast notification with inline approve/deny buttons in ModerationNotifications
- Toast appears immediately when host receives join request (not buried in settings)
- Approve/deny actions handled directly from toast with API calls

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:59:10 -05:00
semantic-release-bot
556e5e4ca0 chore(release): 3.6.3 [skip ci]
## [3.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.2...v3.6.3) (2025-10-14)

### Bug Fixes

* update locked room terminology and allow existing members ([1ddf985](1ddf985938))
2025-10-14 12:50:45 +00:00
Thomas Hallock
1ddf985938 fix: update locked room terminology and allow existing members
Update locked room terminology and implementation:
- Change description from "No members" to "No new members"
- Allow existing members to continue using locked rooms
- Only block new members from joining locked rooms
- Update join API to check membership before rejecting

This clarifies that "locked" means no NEW members, but existing members
can continue to participate in the room.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:49:52 -05:00
semantic-release-bot
8c851462de chore(release): 3.6.2 [skip ci]
## [3.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.1...v3.6.2) (2025-10-14)

### Bug Fixes

* allow join with pending invitation for restricted rooms ([85b2cf9](85b2cf9816))
2025-10-14 12:48:24 +00:00
Thomas Hallock
85b2cf9816 fix: allow join with pending invitation for restricted rooms
Remove premature check that blocked access to restricted rooms. Now:
- Frontend no longer blocks restricted room access upfront
- Backend API checks for pending invitation
- Users with valid invitations can join successfully
- Users without invitations get appropriate error message

This fixes the issue where users with pending invitations couldn't join
restricted rooms via the join link.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:47:28 -05:00
semantic-release-bot
4c6eb01f1e chore(release): 3.6.1 [skip ci]
## [3.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.0...v3.6.1) (2025-10-14)

### Bug Fixes

* join user socket channel to receive approval notifications ([7d08fdd](7d08fdd906))

### Code Refactoring

* remove redundant polling from approval notifications ([0d4f400](0d4f400dca))
2025-10-14 12:44:53 +00:00
Thomas Hallock
7d08fdd906 fix: join user socket channel to receive approval notifications
The socket wasn't receiving join-request-approved events because it hadn't
joined the user-specific channel. Now:

- Fetch viewer ID from /api/viewer endpoint
- Emit 'join-user-channel' with userId on socket connect
- Socket joins `user:${userId}` room to receive moderation events
- Approval notifications now trigger automatic room join

This completes the real-time approval notification flow.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:43:49 -05:00
Thomas Hallock
0d4f400dca refactor: remove redundant polling from approval notifications
Remove polling interval that checked every 5 seconds for approval status.
The socket.io listener provides real-time notifications, making polling
unnecessary and wasteful.

Now relies solely on socket.io for instant approval notifications, which:
- Reduces network traffic
- Simplifies code
- Provides faster response time

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:40:02 -05:00
semantic-release-bot
396b6c07c7 chore(release): 3.6.0 [skip ci]
## [3.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.5.0...v3.6.0) (2025-10-14)

### Features

* add socket listener and polling for approval notifications ([35b4a72](35b4a72c8b))
2025-10-14 12:38:33 +00:00
Thomas Hallock
35b4a72c8b feat: add socket listener and polling for approval notifications
When users request to join an approval-only room, they now receive real-time
notifications when their request is approved:

- Add socket.io-client listener for 'join-request-approved' events
- Implement polling fallback (every 5 seconds) to check approval status
- Automatically join room when approval is detected via socket or polling
- Apply to both share link page and JoinRoomModal

This completes the approval flow - users no longer need to reload the page
to see if their join request was approved.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:37:28 -05:00
semantic-release-bot
ba916e0f65 chore(release): 3.5.0 [skip ci]
## [3.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.4.0...v3.5.0) (2025-10-14)

### Features

* replace access mode dropdown with visual button grid ([e5d0672](e5d0672059))
2025-10-14 12:31:26 +00:00
Thomas Hallock
e5d0672059 feat: replace access mode dropdown with visual button grid
Updated the ModerationPanel Settings tab to use a visual button grid
for access mode selection, matching the CreateRoomModal UX.

Changes:
- Replaced <select> dropdown with 3x2 grid of buttons
- Each button shows emoji + label + description
- Visual feedback for selected state and hover
- Includes all 6 access modes: open, password, approval-only,
  restricted, locked, retired
- Maintains same functionality with improved UX

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:30:26 -05:00
semantic-release-bot
5b4c69693d chore(release): 3.4.0 [skip ci]
## [3.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.1...v3.4.0) (2025-10-14)

### Features

* add waiting state for approval requests in JoinRoomModal ([f9b0429](f9b0429a2e))
2025-10-14 12:29:42 +00:00
Thomas Hallock
f9b0429a2e feat: add waiting state for approval requests in JoinRoomModal
When users enter an approval-only room code in the JoinRoomModal, they now:
- See a prompt to send a join request
- After sending, see a "Waiting for Approval" screen
- Can close the modal and check back later

This matches the UX flow from the share link approval flow.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:28:41 -05:00
semantic-release-bot
34998d6b27 chore(release): 3.3.1 [skip ci]
## [3.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.0...v3.3.1) (2025-10-14)

### Bug Fixes

* add POST handler for join requests API endpoint ([d3e5cdf](d3e5cdfc54))
2025-10-14 12:27:05 +00:00
Thomas Hallock
d3e5cdfc54 fix: add POST handler for join requests API endpoint
Previously the endpoint only had a GET handler, causing a 405 error
when users tried to request approval for approval-only rooms.

Now users can POST to create join requests with optional displayName.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:26:08 -05:00
69 changed files with 7697 additions and 2419 deletions

View File

@@ -1,3 +1,356 @@
## [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)
### Bug Fixes
* **memory-quiz:** prevent input lag during rapid typing in room mode ([b45139b](https://github.com/antialias/soroban-abacus-flashcards/commit/b45139b588d0ab6df4d6c1003c1b65b634e2b041))
## [3.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.2...v3.14.3) (2025-10-15)
### Bug Fixes
* **arcade:** delete old session when room game changes ([98a3a25](https://github.com/antialias/soroban-abacus-flashcards/commit/98a3a2573db51899c41ba02796895d676c4e16ef))
## [3.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.1...v3.14.2) (2025-10-15)
### Bug Fixes
* **room:** update GAME_TYPE_TO_NAME mapping for memory-quiz ([4afa171](https://github.com/antialias/soroban-abacus-flashcards/commit/4afa171af212902120599b3d68f58cfbdf7820b0))
## [3.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.0...v3.14.1) (2025-10-14)
### Bug Fixes
* resolve Memory Quiz room-based multiplayer validation issues ([2ffeade](https://github.com/antialias/soroban-abacus-flashcards/commit/2ffeade43710b5f3fff9991cc84763bbdbf97010))
## [3.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.7...v3.14.0) (2025-10-14)
### Features
* **arcade:** add Change Game functionality for room hosts ([ee39241](https://github.com/antialias/soroban-abacus-flashcards/commit/ee39241e3c9e04202592497d9987eafcb89c00c9))
* **arcade:** add game selection screen with navigation to room page ([4124f1c](https://github.com/antialias/soroban-abacus-flashcards/commit/4124f1cc081f5cb9d6f450f3c2e0cca8a247deba))
### Bug Fixes
* **player-config:** correct label positioning in player settings dialog ([554cc40](https://github.com/antialias/soroban-abacus-flashcards/commit/554cc4063bc756c9c9cd1adf0c1964d3f2f6151b))
### Code Refactoring
* implement in-room game selection UI ([f07b96d](https://github.com/antialias/soroban-abacus-flashcards/commit/f07b96d26eb9f63f3ee55f721139c37ccc34c3df))
* make game_name nullable to support in-room game selection ([a9a6cef](https://github.com/antialias/soroban-abacus-flashcards/commit/a9a6cefafcaf7340902328ef1cb02eb3fdd3aa84))
* **nav:** rename emphasizeGameContext to emphasizePlayerSelection ([6bb7016](https://github.com/antialias/soroban-abacus-flashcards/commit/6bb7016eea1e8ca40204a921db4a8b8fb9a06f73))
## [3.13.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.6...v3.13.7) (2025-10-14)
### Bug Fixes
* **toast:** scope animations to prevent affecting other UI elements ([245ed8a](https://github.com/antialias/soroban-abacus-flashcards/commit/245ed8a625ba848f8cd79d51bfd88600cd77f0b9))
## [3.13.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.5...v3.13.6) (2025-10-14)
### Bug Fixes
* **nav:** navigate to /arcade/room (not /arcade/rooms/{id}) ([1c55f36](https://github.com/antialias/soroban-abacus-flashcards/commit/1c55f3630cb5f07b685555e41baa5a49314f15a3))
## [3.13.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.4...v3.13.5) (2025-10-14)
### Bug Fixes
* **nav:** navigate to room after creation from (+) menu ([21e6e33](https://github.com/antialias/soroban-abacus-flashcards/commit/21e6e33173e7939102a7e6d6a7bd5168a97a49d6))
### Documentation
* add production deployment guide ([6d16436](https://github.com/antialias/soroban-abacus-flashcards/commit/6d164361331fae2135afd84ab6e6f38a241b9170))
## [3.13.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.3...v3.13.4) (2025-10-14)
### Bug Fixes
* **api:** include members and memberPlayers in room creation response ([8320d9e](https://github.com/antialias/soroban-abacus-flashcards/commit/8320d9e730e2b9964e509847dfa504a78b721b5a))
## [3.13.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.2...v3.13.3) (2025-10-14)
### Bug Fixes
* **migrations:** add migration 0009 for display_password column ([040d749](https://github.com/antialias/soroban-abacus-flashcards/commit/040d7495a0801076b252d2574023f5323540db1a))
### Code Refactoring
* replace browser alert() calls with toast notifications ([87ef356](https://github.com/antialias/soroban-abacus-flashcards/commit/87ef35682e5c129033f21b91987fc84a45f43ad3))
## [3.13.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.1...v3.13.2) (2025-10-14)
### Bug Fixes
* **arcade:** only notify room creator of join requests ([bc571e3](https://github.com/antialias/soroban-abacus-flashcards/commit/bc571e3d0d11fe4142680132d551e25ca626d950))
## [3.13.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.0...v3.13.1) (2025-10-14)
### Bug Fixes
* **arcade:** allow room creator to rejoin restricted/approval rooms ([654ba19](https://github.com/antialias/soroban-abacus-flashcards/commit/654ba19ccca595d34ad205c036c18afb99a494c7))
## [3.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.12.0...v3.13.0) (2025-10-14)
### Features
* **moderation:** add inline feedback and persistent password display ([86e3d41](https://github.com/antialias/soroban-abacus-flashcards/commit/86e3d4199628f95048b9265c9de0adfdc2934f93))
## [3.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.1...v3.12.0) (2025-10-14)
### Features
* **moderation:** improve password input with copy button ([2580e47](https://github.com/antialias/soroban-abacus-flashcards/commit/2580e474d08bf91477339e998b2c70962a633f41))
## [3.11.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.0...v3.11.1) (2025-10-14)
### Bug Fixes
* **moderation:** improve access mode settings UX ([dd9e657](https://github.com/antialias/soroban-abacus-flashcards/commit/dd9e657db85752b32ff91ae1b33a0bf7a7628e07))
## [3.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.10.0...v3.11.0) (2025-10-14)
### Features
* add name generator button and abacus emoji ([07212e4](https://github.com/antialias/soroban-abacus-flashcards/commit/07212e4df0c7fd4b8cccf935c48b14164df6961d))
### Code Refactoring
* make player names abacus and arithmetic themed ([97daad9](https://github.com/antialias/soroban-abacus-flashcards/commit/97daad9abb40a6f4d59ca8a4d4b671822b7b0955))
## [3.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.2...v3.10.0) (2025-10-14)
### Features
* add fun automatic player naming system ([249257c](https://github.com/antialias/soroban-abacus-flashcards/commit/249257c6c77d503b48479065664c96c5de36a234))
## [3.9.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.1...v3.9.2) (2025-10-14)
### Bug Fixes
* remove duplicate ModerationNotifications causing double toasts ([c6886a0](https://github.com/antialias/soroban-abacus-flashcards/commit/c6886a0e59b3cbf051a828e0157495101cd8c823))
## [3.9.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.0...v3.9.1) (2025-10-14)
### Bug Fixes
* reset join request toast state when moderation event cleared ([6beb58a](https://github.com/antialias/soroban-abacus-flashcards/commit/6beb58a7b8f8e1841c71729a3517ab459e924aa9))
## [3.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.1...v3.9.0) (2025-10-14)
### Features
* prevent invitations to retired rooms ([a7c3c1f](https://github.com/antialias/soroban-abacus-flashcards/commit/a7c3c1f4cd802985c8f040bc1cdf3ea4482a2fce))
## [3.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.0...v3.8.1) (2025-10-14)
### Bug Fixes
* improve kicked modal message for retired room ejections ([f865ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/f865ce16ecf7648e41549795c8137f4fc33e34ac))
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.0) (2025-10-14)
### Features
* implement proper retired room behavior with member expulsion ([a2d5368](https://github.com/antialias/soroban-abacus-flashcards/commit/a2d53680f27db04b2cd09973e62a76c5a7d4ce06))
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)
### Bug Fixes
* improve join request approval error handling with actionable messages ([57bf846](https://github.com/antialias/soroban-abacus-flashcards/commit/57bf8460c8ecff374355bfb93f4b06dfbb148273))
## [3.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.3...v3.7.0) (2025-10-14)
### Features
* add prominent join request approval notifications for room moderators ([036da6d](https://github.com/antialias/soroban-abacus-flashcards/commit/036da6de66ca7d3f459c55df657b04a9e88d9cd3))
## [3.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.2...v3.6.3) (2025-10-14)
### Bug Fixes
* update locked room terminology and allow existing members ([1ddf985](https://github.com/antialias/soroban-abacus-flashcards/commit/1ddf985938d9542fe26e44da58234f3d4e3c9543))
## [3.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.1...v3.6.2) (2025-10-14)
### Bug Fixes
* allow join with pending invitation for restricted rooms ([85b2cf9](https://github.com/antialias/soroban-abacus-flashcards/commit/85b2cf98167ccf632ab634a94eb436e1eb584614))
## [3.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.0...v3.6.1) (2025-10-14)
### Bug Fixes
* join user socket channel to receive approval notifications ([7d08fdd](https://github.com/antialias/soroban-abacus-flashcards/commit/7d08fdd90643920857eda09998ac01afbae74154))
### Code Refactoring
* remove redundant polling from approval notifications ([0d4f400](https://github.com/antialias/soroban-abacus-flashcards/commit/0d4f400dca02ad9497522c24fded8b6d07d85fd2))
## [3.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.5.0...v3.6.0) (2025-10-14)
### Features
* add socket listener and polling for approval notifications ([35b4a72](https://github.com/antialias/soroban-abacus-flashcards/commit/35b4a72c8b2f80a74b5d2fe02b048d4ec4d1d6f2))
## [3.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.4.0...v3.5.0) (2025-10-14)
### Features
* replace access mode dropdown with visual button grid ([e5d0672](https://github.com/antialias/soroban-abacus-flashcards/commit/e5d067205989d7c3105998dcd7d67fd0408f332c))
## [3.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.1...v3.4.0) (2025-10-14)
### Features
* add waiting state for approval requests in JoinRoomModal ([f9b0429](https://github.com/antialias/soroban-abacus-flashcards/commit/f9b0429a2e2d22944acba66009dd87a9d9eb28c2))
## [3.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.0...v3.3.1) (2025-10-14)
### Bug Fixes
* add POST handler for join requests API endpoint ([d3e5cdf](https://github.com/antialias/soroban-abacus-flashcards/commit/d3e5cdfc54f2749f27c6f8b8db854a8d0b6029f8))
## [3.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.2.1...v3.3.0) (2025-10-14)

View File

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

View File

@@ -0,0 +1,191 @@
# Production Deployment Guide
This document describes the production deployment infrastructure and procedures for the abaci.one web application.
## Infrastructure Overview
### Production Server
- **Host**: `nas.home.network` (Synology NAS DS923+)
- **Access**: SSH access required
- Must be connected to network at **730 N. Oak Park Ave**
- Server is not accessible from external networks
- **Project Directory**: `/volume1/homes/antialias/projects/abaci.one`
### Docker Configuration
- **Docker binary**: `/usr/local/bin/docker`
- **Docker Compose binary**: `/usr/local/bin/docker-compose`
- **Container name**: `soroban-abacus-flashcards`
- **Image**: `ghcr.io/antialias/soroban-abacus-flashcards:latest`
### Auto-Deployment
- **Watchtower** monitors and auto-updates containers
- **Update frequency**: Every **5 minutes**
- Watchtower pulls latest images and restarts containers automatically
- No manual intervention required for deployments after pushing to main
## Database Management
### Location
- **Database path**: `data/sqlite.db` (relative to project directory)
- **WAL files**: `data/sqlite.db-shm` and `data/sqlite.db-wal`
### Migrations
- **Automatic**: Migrations run on server startup via `server.js`
- **Migration folder**: `./drizzle`
- **Process**:
1. Server starts
2. Logs: `🔄 Running database migrations...`
3. Drizzle migrator runs all pending migrations
4. Logs: `✅ Migrations complete` (on success)
5. Logs: `❌ Migration failed: [error]` (on failure, process exits)
### Nuke and Rebuild Database
If you need to completely reset the production database:
```bash
# SSH into the server
ssh nas.home.network
# Navigate to project directory
cd /volume1/homes/antialias/projects/abaci.one
# Stop the container
/usr/local/bin/docker-compose down
# Remove database files
rm -f data/sqlite.db data/sqlite.db-shm data/sqlite.db-wal
# Restart container (migrations will rebuild DB)
/usr/local/bin/docker-compose up -d
# Check logs to verify migration success
/usr/local/bin/docker logs soroban-abacus-flashcards | grep -E '(Migration|Starting)'
```
## CI/CD Pipeline
### GitHub Actions
When code is pushed to `main` branch:
1. **Workflows triggered**:
- `Build and Deploy` - Builds Docker image and pushes to GHCR
- `Release` - Manages semantic versioning and releases
- `Verify Examples` - Runs example tests
- `Deploy Storybooks to GitHub Pages` - Publishes Storybook
2. **Image build**:
- Built image is tagged as `latest`
- Pushed to GitHub Container Registry (ghcr.io)
- Typically completes within 1-2 minutes
3. **Deployment**:
- Watchtower detects new image (within 5 minutes)
- Pulls latest image
- Recreates and restarts container
- Total deployment time: ~5-7 minutes from push to production
## Manual Deployment Procedures
### Force Pull Latest Image
If you need to immediately deploy without waiting for Watchtower:
```bash
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && /usr/local/bin/docker-compose pull && /usr/local/bin/docker-compose up -d"
```
### Check Container Status
```bash
ssh nas.home.network "/usr/local/bin/docker ps | grep -E '(soroban|abaci)'"
```
### View Logs
```bash
# Recent logs
ssh nas.home.network "/usr/local/bin/docker logs --tail 100 soroban-abacus-flashcards"
# Follow logs in real-time
ssh nas.home.network "/usr/local/bin/docker logs -f soroban-abacus-flashcards"
# Search for specific patterns
ssh nas.home.network "/usr/local/bin/docker logs soroban-abacus-flashcards" | grep -i "error"
```
### Restart Container
```bash
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && /usr/local/bin/docker-compose restart"
```
## Deployment Script
The project includes a deployment script at `nas-deployment/deploy.sh` for manual deployments.
## Troubleshooting
### Common Issues
#### 1. Migration Failures
**Symptom**: Container keeps restarting, logs show migration errors
**Solution**:
1. Check migration files in `drizzle/` directory
2. Verify `drizzle/meta/_journal.json` is up to date
3. If migrations are corrupted, may need to nuke database (see above)
#### 2. Container Not Updating
**Symptom**: Changes pushed but production still shows old code
**Possible causes**:
- GitHub Actions build failed - check workflow status with `gh run list`
- Watchtower not running - check with `docker ps | grep watchtower`
- Image not pulled - manually pull with `docker-compose pull`
**Solution**:
```bash
# Force pull and restart
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && /usr/local/bin/docker-compose pull && /usr/local/bin/docker-compose up -d"
```
#### 3. Missing Database Columns
**Symptom**: Errors like `SqliteError: no such column: "column_name"`
**Cause**: Migration not registered or not run
**Solution**:
1. Verify migration exists in `drizzle/` directory
2. Check migration is registered in `drizzle/meta/_journal.json`
3. If migration is new, restart container to run migrations
4. If migration is malformed, fix it and nuke database
#### 4. API Returns Unexpected Response
**Symptom**: Client shows errors but API appears to work
**Debugging**:
1. Test API directly with curl: `curl -X POST 'https://abaci.one/api/arcade/rooms' -H 'Content-Type: application/json' -d '...'`
2. Check production logs for errors
3. Verify container is running latest image:
```bash
ssh nas.home.network "/usr/local/bin/docker inspect soroban-abacus-flashcards --format '{{.Created}}'"
```
4. Compare with commit timestamp: `git log --format="%ci" -1`
## Environment Variables
Production environment variables are configured in the docker-compose.yml file on the server. Common variables:
- `NEXT_PUBLIC_URL` - Base URL for the application
- `DATABASE_URL` - SQLite database path
- Additional variables may be set in `.env.production` or docker-compose.yml
## Network Configuration
- **Reverse Proxy**: Traefik
- **HTTPS**: Automatic via Traefik with Let's Encrypt
- **Domain**: abaci.one
- **Exposed Port**: 3000 (internal to Docker network)
## Security Notes
- Production database contains user data and should be handled carefully
- SSH access is restricted to local network only
- Docker container runs with appropriate user permissions
- Secrets are managed via environment variables, not committed to repo

View File

@@ -57,7 +57,20 @@
"Bash(fi)",
"Bash(then echo \"TypeScript errors found\")",
"Bash(else echo \"✓ No TypeScript errors in join page\")",
"Bash(npx @biomejs/biome format:*)"
"Bash(npx @biomejs/biome format:*)",
"Bash(npx drizzle-kit generate:*)",
"Bash(ssh nas.home.network \"docker ps | grep -E ''soroban|abaci|web''\")",
"Bash(ssh:*)",
"Bash(printf \"\\n\\n\")",
"Bash(timeout 10 npx drizzle-kit generate:*)",
"Bash(git checkout:*)",
"Bash(git log:*)",
"Bash(python3:*)",
"Bash(git reset:*)",
"Bash(lsof:*)",
"Bash(killall:*)",
"Bash(echo:*)",
"Bash(git restore:*)"
],
"deny": [],
"ask": []

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

View File

@@ -0,0 +1,2 @@
-- Add display_password column to arcade_rooms for showing plain text passwords to room owners
ALTER TABLE `arcade_rooms` ADD `display_password` text(100);

View File

@@ -0,0 +1,42 @@
-- Make game_name and game_config nullable to support game selection in room
-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table
PRAGMA foreign_keys=OFF;--> statement-breakpoint
-- Create temporary table with correct schema
CREATE TABLE `arcade_rooms_new` (
`id` text PRIMARY KEY NOT NULL,
`code` text(6) NOT NULL,
`name` text(50),
`created_by` text NOT NULL,
`creator_name` text(50) NOT NULL,
`created_at` integer NOT NULL,
`last_activity` integer NOT NULL,
`ttl_minutes` integer DEFAULT 60 NOT NULL,
`access_mode` text DEFAULT 'open' NOT NULL,
`password` text(255),
`display_password` text(100),
`game_name` text,
`game_config` text,
`status` text DEFAULT 'lobby' NOT NULL,
`current_session_id` text,
`total_games_played` integer DEFAULT 0 NOT NULL
);--> statement-breakpoint
-- Copy all data
INSERT INTO `arcade_rooms_new`
SELECT `id`, `code`, `name`, `created_by`, `creator_name`, `created_at`,
`last_activity`, `ttl_minutes`, `access_mode`, `password`, `display_password`,
`game_name`, `game_config`, `status`, `current_session_id`, `total_games_played`
FROM `arcade_rooms`;--> statement-breakpoint
-- Drop old table
DROP TABLE `arcade_rooms`;--> statement-breakpoint
-- Rename new table
ALTER TABLE `arcade_rooms_new` RENAME TO `arcade_rooms`;--> statement-breakpoint
-- Recreate index
CREATE UNIQUE INDEX `arcade_rooms_code_unique` ON `arcade_rooms` (`code`);--> statement-breakpoint
PRAGMA foreign_keys=ON;

View File

@@ -64,6 +64,20 @@
"when": 1760548800000,
"tag": "0008_make_room_name_nullable",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1760600000000,
"tag": "0009_add_display_password",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1760700000000,
"tag": "0010_make_game_name_nullable",
"breakpoints": true
}
]
}

View File

@@ -1,13 +1,14 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import {
createInvitation,
declineInvitation,
getInvitation,
getRoomInvitations,
} from '@/lib/arcade/room-invitations'
import { getViewerId } from '@/lib/viewer'
import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -35,6 +36,20 @@ export async function POST(req: NextRequest, context: RouteContext) {
)
}
// Get room to check access mode
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Cannot invite to retired rooms
if (room.accessMode === 'retired') {
return NextResponse.json(
{ error: 'Cannot send invitations to retired rooms' },
{ status: 403 }
)
}
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { createJoinRequest, getPendingJoinRequests } from '@/lib/arcade/room-join-requests'
import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getPendingJoinRequests } from '@/lib/arcade/room-join-requests'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
@@ -37,3 +39,82 @@ export async function GET(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'Failed to get join requests' }, { status: 500 })
}
}
/**
* POST /api/arcade/rooms/:roomId/join-requests
* Create a join request for an approval-only room
* Body:
* - displayName?: string (optional, will generate from viewerId if not provided)
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json().catch(() => ({}))
// Get room to verify it exists
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Verify room is approval-only
if (room.accessMode !== 'approval-only') {
return NextResponse.json(
{ error: 'This room does not require approval to join' },
{ status: 400 }
)
}
// Get or generate display name
const displayName = body.displayName || `Guest ${viewerId.slice(-4)}`
// Validate display name length
if (displayName.length > 50) {
return NextResponse.json(
{ error: 'Display name too long (max 50 characters)' },
{ status: 400 }
)
}
// Create join request
const request = await createJoinRequest({
roomId,
userId: viewerId,
userName: displayName,
})
console.log(
`[Join Requests] Created request for user ${viewerId} (${displayName}) to join room ${roomId}`
)
// Broadcast to the room host (creator) only via socket
const io = await getSocketIO()
if (io) {
try {
// Send notification only to the room creator's user channel
io.to(`user:${room.createdBy}`).emit('join-request-submitted', {
roomId,
request: {
id: request.id,
userId: request.userId,
userName: request.userName,
createdAt: request.requestedAt,
},
})
console.log(
`[Join Requests] Broadcasted join-request-submitted to room creator ${room.createdBy}`
)
} catch (socketError) {
// Log but don't fail the request if socket broadcast fails
console.error('[Join Requests] Failed to broadcast join-request-submitted:', socketError)
}
}
return NextResponse.json({ request }, { status: 201 })
} catch (error: any) {
console.error('Failed to create join request:', error)
return NextResponse.json({ error: 'Failed to create join request' }, { status: 500 })
}
}

View File

@@ -1,13 +1,13 @@
import bcrypt from 'bcryptjs'
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { isUserBanned } from '@/lib/arcade/room-moderation'
import { getInvitation } from '@/lib/arcade/room-invitations'
import { getJoinRequest } from '@/lib/arcade/room-join-requests'
import { getViewerId } from '@/lib/viewer'
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
import { isUserBanned } from '@/lib/arcade/room-moderation'
import { getSocketIO } from '@/lib/socket-io'
import bcrypt from 'bcryptjs'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -38,13 +38,32 @@ export async function POST(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'You are banned from this room' }, { status: 403 })
}
// Check if user is already a member (for locked/retired room access)
const members = await getRoomMembers(roomId)
const isExistingMember = members.some((m) => m.userId === viewerId)
const isRoomCreator = room.createdBy === viewerId
// Validate access mode
switch (room.accessMode) {
case 'locked':
return NextResponse.json({ error: 'This room is locked' }, { status: 403 })
// Allow existing members to continue using the room, but block new members
if (!isExistingMember) {
return NextResponse.json(
{ error: 'This room is locked and not accepting new members' },
{ status: 403 }
)
}
break
case 'retired':
return NextResponse.json({ error: 'This room has been retired' }, { status: 410 })
// Only the room creator can access retired rooms
if (!isRoomCreator) {
return NextResponse.json(
{ error: 'This room has been retired and is only accessible to the owner' },
{ status: 410 }
)
}
break
case 'password': {
if (!body.password) {
@@ -64,30 +83,34 @@ export async function POST(req: NextRequest, context: RouteContext) {
}
case 'restricted': {
// Check for valid pending invitation
const invitation = await getInvitation(roomId, viewerId)
if (!invitation || invitation.status !== 'pending') {
return NextResponse.json(
{ error: 'You need a valid invitation to join this room' },
{ status: 403 }
)
// Room creator can always rejoin their own room
if (!isRoomCreator) {
// Check for valid pending invitation
const invitation = await getInvitation(roomId, viewerId)
if (!invitation || invitation.status !== 'pending') {
return NextResponse.json(
{ error: 'You need a valid invitation to join this room' },
{ status: 403 }
)
}
}
break
}
case 'approval-only': {
// Check for approved join request
const joinRequest = await getJoinRequest(roomId, viewerId)
if (!joinRequest || joinRequest.status !== 'approved') {
return NextResponse.json(
{ error: 'Your join request must be approved by the host' },
{ status: 403 }
)
// Room creator can always rejoin their own room without approval
if (!isRoomCreator) {
// Check for approved join request
const joinRequest = await getJoinRequest(roomId, viewerId)
if (!joinRequest || joinRequest.status !== 'approved') {
return NextResponse.json(
{ error: 'Your join request must be approved by the host' },
{ status: 403 }
)
}
}
break
}
case 'open':
default:
// No additional checks needed
break

View File

@@ -42,8 +42,13 @@ export async function GET(_req: NextRequest, context: RouteContext) {
// Update room activity when viewing (keeps active rooms fresh)
await touchRoom(roomId)
// Prepare room data - include displayPassword only for room creator
const roomData = canModerate
? room // Creator gets full room data including displayPassword
: { ...room, displayPassword: undefined } // Others don't see displayPassword
return NextResponse.json({
room,
room: roomData,
members,
memberPlayers, // Map of userId -> active Player[] for each member
canModerate,

View File

@@ -1,9 +1,12 @@
import { eq } from 'drizzle-orm'
import bcrypt from 'bcryptjs'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { recordRoomMemberHistory } from '@/lib/arcade/room-member-history'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
import bcrypt from 'bcryptjs'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -15,6 +18,8 @@ type RouteContext = {
* Body:
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
* - password?: string (plain text, will be hashed)
* - gameName?: 'matching' | 'memory-quiz' | 'complement-race' | null (select game for room)
* - gameConfig?: object (game-specific settings)
*/
export async function PATCH(req: NextRequest, context: RouteContext) {
try {
@@ -22,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)
@@ -55,6 +90,14 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
)
}
// Validate gameName if provided
if (body.gameName !== undefined && body.gameName !== null) {
const validGames = ['matching', 'memory-quiz', 'complement-race']
if (!validGames.includes(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
}
}
// Prepare update data
const updateData: Record<string, any> = {}
@@ -66,12 +109,36 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
if (body.password !== undefined) {
if (body.password === null || body.password === '') {
updateData.password = null // Clear password
updateData.displayPassword = null // Also clear display password
} else {
const hashedPassword = await bcrypt.hash(body.password, 10)
updateData.password = hashedPassword
updateData.displayPassword = body.password // Store plain text for display
}
}
// Update game selection if provided
if (body.gameName !== undefined) {
updateData.gameName = body.gameName
}
// Update game config if provided
if (body.gameConfig !== undefined) {
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) {
console.log(`[Settings API] Deleting existing arcade session for room ${roomId}`)
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, roomId))
}
// Update room settings
const [updatedRoom] = await db
.update(schema.arcadeRooms)
@@ -79,6 +146,111 @@ 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)
if (nonOwnerMembers.length > 0) {
// Remove all non-owner members from the room
await db.delete(schema.roomMembers).where(
and(
eq(schema.roomMembers.roomId, roomId),
// Delete all members except the creator
eq(schema.roomMembers.isCreator, false)
)
)
// Record in history for each expelled member
for (const member of nonOwnerMembers) {
await recordRoomMemberHistory({
roomId,
userId: member.userId,
displayName: member.displayName,
action: 'left',
})
}
// Broadcast updates via socket
const io = await getSocketIO()
if (io) {
try {
// Get updated member list (should only be the owner now)
const updatedMembers = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Notify each expelled member
for (const member of nonOwnerMembers) {
io.to(`user:${member.userId}`).emit('kicked-from-room', {
roomId,
kickedBy: currentMember.displayName,
reason: 'Room has been retired',
})
}
// Notify the owner that members were expelled
io.to(`room:${roomId}`).emit('member-left', {
roomId,
userId: nonOwnerMembers.map((m) => m.userId),
members: updatedMembers,
memberPlayers: memberPlayersObj,
reason: 'room-retired',
})
console.log(
`[Settings API] Expelled ${nonOwnerMembers.length} members from retired room ${roomId}`
)
} catch (socketError) {
console.error('[Settings API] Failed to broadcast member expulsion:', socketError)
}
}
}
}
return NextResponse.json({ room: updatedRoom }, { status: 200 })
} catch (error: any) {
console.error('Failed to update room settings:', error)

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,15 +70,12 @@ export async function POST(req: NextRequest) {
const viewerId = await getViewerId()
const body = await req.json()
// Validate required fields (name is optional, gameName is required)
if (!body.gameName) {
return NextResponse.json({ error: 'Missing required field: gameName' }, { status: 400 })
}
// Validate game name
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
if (!validGames.includes(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
// Validate game name if provided (gameName is now optional)
if (body.gameName) {
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
if (!validGames.includes(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
}
}
// Validate name length (if provided)
@@ -120,8 +117,8 @@ export async function POST(req: NextRequest) {
name: roomName,
createdBy: viewerId,
creatorName: displayName,
gameName: body.gameName,
gameConfig: body.gameConfig || {},
gameName: body.gameName || null,
gameConfig: body.gameConfig || null,
ttlMinutes: body.ttlMinutes,
accessMode: body.accessMode,
password: body.password,
@@ -135,6 +132,16 @@ export async function POST(req: NextRequest) {
isCreator: true,
})
// Get members and active players for the response
const members = await getRoomMembers(room.id)
const memberPlayers = await getRoomActivePlayers(room.id)
// Convert Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Generate join URL
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'
const joinUrl = `${baseUrl}/arcade/rooms/${room.id}`
@@ -142,6 +149,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json(
{
room,
members,
memberPlayers: memberPlayersObj,
joinUrl,
},
{ status: 201 }

View File

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

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { io, type Socket } from 'socket.io-client'
import { css } from '../../../../styled-system/css'
import { useToast } from '@/components/common/ToastContext'
import { PageWithNav } from '@/components/PageWithNav'
import { useViewerId } from '@/hooks/useViewerId'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
@@ -40,6 +41,7 @@ interface Player {
export default function RoomDetailPage() {
const params = useParams()
const router = useRouter()
const { showError } = useToast()
const roomId = params.roomId as string
const { data: guestId } = useViewerId()
@@ -172,7 +174,7 @@ export default function RoomDetailPage() {
// Handle specific room membership conflict
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
alert(errorData.userMessage || errorData.message)
showError('Already in Another Room', errorData.userMessage || errorData.message)
// Refresh the page to update room state
await fetchRoom()
return
@@ -193,7 +195,7 @@ export default function RoomDetailPage() {
await fetchRoom()
} catch (err) {
console.error('Failed to join room:', err)
alert('Failed to join room')
showError('Failed to join room', err instanceof Error ? err.message : undefined)
}
}
@@ -213,7 +215,7 @@ export default function RoomDetailPage() {
router.push('/arcade')
} catch (err) {
console.error('Failed to leave room:', err)
alert('Failed to leave room')
showError('Failed to leave room', err instanceof Error ? err.message : undefined)
}
}

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { css } from '../../../styled-system/css'
import { useToast } from '@/components/common/ToastContext'
import { PageWithNav } from '@/components/PageWithNav'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
@@ -23,6 +24,7 @@ interface Room {
export default function RoomBrowserPage() {
const router = useRouter()
const { showError, showInfo } = useToast()
const [rooms, setRooms] = useState<Room[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -68,10 +70,10 @@ 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)
alert('Failed to create room')
showError('Failed to create room', err instanceof Error ? err.message : undefined)
}
}
@@ -90,7 +92,7 @@ export default function RoomBrowserPage() {
if (!response.ok) {
const errorData = await response.json()
alert(errorData.error || 'Failed to join room')
showError('Failed to join room', errorData.error)
return
}
@@ -99,12 +101,18 @@ export default function RoomBrowserPage() {
}
if (room.accessMode === 'approval-only') {
alert('This room requires host approval. Please use the Join Room modal to request access.')
showInfo(
'Approval Required',
'This room requires host approval. Please use the Join Room modal to request access.'
)
return
}
if (room.accessMode === 'restricted') {
alert('This room is invitation-only. Please ask the host for an invitation.')
showInfo(
'Invitation Only',
'This room is invitation-only. Please ask the host for an invitation.'
)
return
}
@@ -120,7 +128,7 @@ export default function RoomBrowserPage() {
// Handle specific room membership conflict
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
alert(errorData.userMessage || errorData.message)
showError('Already in Another Room', errorData.userMessage || errorData.message)
// Refresh the page to update room list state
await fetchRooms()
return
@@ -140,7 +148,7 @@ export default function RoomBrowserPage() {
router.push(`/arcade-rooms/${room.id}`)
} catch (err) {
console.error('Failed to join room:', err)
alert('Failed to join room')
showError('Failed to join room', err instanceof Error ? err.message : undefined)
}
}

View File

@@ -33,7 +33,7 @@ export function MemoryPairsGame() {
<PageWithNav
navTitle={navTitle}
navEmoji={navEmoji}
emphasizeGameContext={state.gamePhase === 'setup'}
emphasizePlayerSelection={state.gamePhase === 'setup'}
onExitSession={() => {
exitSession()
router.push('/arcade')

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useReducer } from 'react'
import { useRouter } from 'next/navigation'
import { initialState, quizReducer } from '../reducer'
import type { QuizCard } from '../types'
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
interface LocalMemoryQuizProviderProps {
children: ReactNode
}
/**
* LocalMemoryQuizProvider - Provides context for single-player local mode
*
* This provider wraps the memory quiz reducer and provides action creators
* to child components. It's used for standalone local play (non-room mode).
*
* Action creators wrap dispatch calls to maintain same interface as RoomProvider.
*/
export function LocalMemoryQuizProvider({ children }: LocalMemoryQuizProviderProps) {
const router = useRouter()
const [state, dispatch] = useReducer(quizReducer, initialState)
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (state.prefixAcceptanceTimeout) {
clearTimeout(state.prefixAcceptanceTimeout)
}
}
}, [state.prefixAcceptanceTimeout])
// Computed values
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
// Action creators - wrap dispatch calls to match RoomProvider interface
const startQuiz = useCallback((quizCards: QuizCard[]) => {
dispatch({ type: 'START_QUIZ', quizCards })
}, [])
const nextCard = useCallback(() => {
dispatch({ type: 'NEXT_CARD' })
}, [])
const showInputPhase = useCallback(() => {
dispatch({ type: 'SHOW_INPUT_PHASE' })
}, [])
const acceptNumber = useCallback((number: number) => {
dispatch({ type: 'ACCEPT_NUMBER', number })
}, [])
const rejectNumber = useCallback(() => {
dispatch({ type: 'REJECT_NUMBER' })
}, [])
const setInput = useCallback((input: string) => {
dispatch({ type: 'SET_INPUT', input })
}, [])
const showResults = useCallback(() => {
dispatch({ type: 'SHOW_RESULTS' })
}, [])
const resetGame = useCallback(() => {
dispatch({ type: 'RESET_QUIZ' })
}, [])
const setConfig = useCallback(
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => {
switch (field) {
case 'selectedCount':
dispatch({ type: 'SET_SELECTED_COUNT', count: value })
break
case 'displayTime':
dispatch({ type: 'SET_DISPLAY_TIME', time: value })
break
case 'selectedDifficulty':
dispatch({ type: 'SET_DIFFICULTY', difficulty: value })
break
}
},
[]
)
const exitSession = useCallback(() => {
router.push('/games')
}, [router])
const contextValue: MemoryQuizContextValue = {
state,
dispatch: () => {
// No-op - local provider uses action creators instead
console.warn('dispatch() is not available in local mode, use action creators instead')
},
isGameActive,
resetGame,
exitSession,
// Expose action creators for components to use
startQuiz,
nextCard,
showInputPhase,
acceptNumber,
rejectNumber,
setInput,
showResults,
setConfig,
}
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
}

View File

@@ -0,0 +1,45 @@
'use client'
import { createContext, useContext } from 'react'
import type { QuizAction, QuizCard, SorobanQuizState } from '../types'
// Context value interface
export interface MemoryQuizContextValue {
state: SorobanQuizState
dispatch: React.Dispatch<QuizAction>
// Computed values
isGameActive: boolean
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
startGame?: () => void
resetGame?: () => void
exitSession?: () => void
// Room mode action creators (optional for local mode)
startQuiz?: (quizCards: QuizCard[]) => void
nextCard?: () => void
showInputPhase?: () => void
acceptNumber?: (number: number) => void
rejectNumber?: () => void
setInput?: (input: string) => void
showResults?: () => void
setConfig?: (
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: any
) => void
}
// Create context
export const MemoryQuizContext = createContext<MemoryQuizContextValue | null>(null)
// Hook to use the context
export function useMemoryQuiz(): MemoryQuizContextValue {
const context = useContext(MemoryQuizContext)
if (!context) {
throw new Error('useMemoryQuiz must be used within a MemoryQuizProvider')
}
return context
}

View File

@@ -0,0 +1,556 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useArcadeSession } from '@/hooks/useArcadeSession'
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'
/**
* Optimistic move application (client-side prediction)
* The server will validate and send back the authoritative state
*/
function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): SorobanQuizState {
switch (move.type) {
case 'START_QUIZ': {
// Handle both client-generated moves (with quizCards) and server-generated moves (with numbers only)
// Server can't serialize React components, so it only sends numbers
const clientQuizCards = move.data.quizCards
const serverNumbers = move.data.numbers
let quizCards: QuizCard[]
let correctAnswers: number[]
if (clientQuizCards) {
// Client-side optimistic update: use the full quizCards with React components
quizCards = clientQuizCards
correctAnswers = clientQuizCards.map((card: QuizCard) => card.number)
} else if (serverNumbers) {
// Server update: create minimal quizCards from numbers (no React components needed for validation)
quizCards = serverNumbers.map((number: number) => ({
number,
svgComponent: null,
element: null,
}))
correctAnswers = serverNumbers
} else {
// Fallback: preserve existing state
quizCards = state.quizCards
correctAnswers = state.correctAnswers
}
const cardCount = quizCards.length
// 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,
correctAnswers,
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: cardCount + Math.floor(cardCount / 2),
gamePhase: 'display',
incorrectGuesses: 0,
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
// Multiplayer state
activePlayers,
playerMetadata,
playerScores,
}
}
case 'NEXT_CARD':
return {
...state,
currentCardIndex: state.currentCardIndex + 1,
}
case 'SHOW_INPUT_PHASE':
return {
...state,
gamePhase: 'input',
}
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: [...foundNumbers, move.data.number],
playerScores: newPlayerScores,
numberFoundBy: newNumberFoundBy,
}
}
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,
playerScores: newPlayerScores,
}
}
case 'SHOW_RESULTS':
return {
...state,
gamePhase: 'results',
}
case 'RESET_QUIZ':
return {
...state,
gamePhase: 'setup',
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
}
case 'SET_CONFIG': {
const { field, value } = move.data as {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
value: any
}
return {
...state,
[field]: value,
}
}
default:
return state
}
}
/**
* RoomMemoryQuizProvider - Provides context for room-based multiplayer mode
*
* This provider uses useArcadeSession for network-synchronized gameplay.
* All state changes are sent as moves and validated on the server.
*/
export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
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 - gameConfig:', gameConfig)
if (!gameConfig) {
console.log('[RoomMemoryQuizProvider] No gameConfig, using initialState')
return initialState
}
// Get settings for this specific game (memory-quiz)
const savedConfig = gameConfig['memory-quiz'] as Record<string, any> | null | undefined
console.log('[RoomMemoryQuizProvider] Loading saved config for memory-quiz:', savedConfig)
if (!savedConfig) {
console.log('[RoomMemoryQuizProvider] No saved config for memory-quiz, using initialState')
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:', {
selectedCount: merged.selectedCount,
displayTime: merged.displayTime,
selectedDifficulty: merged.selectedDifficulty,
playMode: merged.playMode,
})
return merged
}, [roomData?.gameConfig])
// Arcade session integration WITH room sync
const {
state,
sendMove,
connected: _connected,
exitSession,
} = useArcadeSession<SorobanQuizState>({
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
initialState: 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 () => {
if (state.prefixAcceptanceTimeout) {
clearTimeout(state.prefixAcceptanceTimeout)
}
}
}, [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
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: 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, activePlayers, buildPlayerMetadata]
)
const nextCard = useCallback(() => {
sendMove({
type: 'NEXT_CARD',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const showInputPhase = useCallback(() => {
sendMove({
type: 'SHOW_INPUT_PHASE',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const acceptNumber = useCallback(
(number: number) => {
// Clear local input immediately
setLocalCurrentInput('')
sendMove({
type: 'ACCEPT_NUMBER',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed correctly
data: { number },
})
},
[viewerId, sendMove]
)
const rejectNumber = useCallback(() => {
// Clear local input immediately
setLocalCurrentInput('')
sendMove({
type: 'REJECT_NUMBER',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed incorrectly
data: {},
})
}, [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: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const resetGame = useCallback(() => {
sendMove({
type: 'RESET_QUIZ',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const setConfig = useCallback(
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode', value: any) => {
console.log(`[RoomMemoryQuizProvider] setConfig called: ${field} = ${value}`)
sendMove({
type: 'SET_CONFIG',
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, 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: mergedState,
dispatch: () => {
// No-op - replaced with action creators
console.warn('dispatch() is deprecated in room mode, use action creators instead')
},
isGameActive,
resetGame,
exitSession,
isRoomCreator, // Pass room creator flag to components
// Expose action creators for components to use
startQuiz,
nextCard,
showInputPhase,
acceptNumber,
rejectNumber,
setInput,
showResults,
setConfig,
}
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
}
// Export the hook for this provider
export { useMemoryQuiz } from './MemoryQuizContext'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
import type { QuizAction, SorobanQuizState } from './types'
export const initialState: SorobanQuizState = {
cards: [],
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
displayTime: 2.0,
selectedCount: 5,
selectedDifficulty: 'easy', // Default to easy level
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
// Multiplayer state
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: 'cooperative', // Default to cooperative
numberFoundBy: {},
// UI state
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
wrongGuessAnimations: [],
// Keyboard state (persistent across re-renders)
hasPhysicalKeyboard: null,
testingMode: false,
showOnScreenKeyboard: false,
}
export function quizReducer(state: SorobanQuizState, action: QuizAction): SorobanQuizState {
switch (action.type) {
case 'SET_CARDS':
return { ...state, cards: action.cards }
case 'SET_DISPLAY_TIME':
return { ...state, displayTime: action.time }
case 'SET_SELECTED_COUNT':
return { ...state, selectedCount: action.count }
case 'SET_DIFFICULTY':
return { ...state, selectedDifficulty: action.difficulty }
case 'SET_PLAY_MODE':
return { ...state, playMode: action.playMode }
case 'START_QUIZ':
return {
...state,
quizCards: action.quizCards,
correctAnswers: action.quizCards.map((card) => card.number),
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: action.quizCards.length + Math.floor(action.quizCards.length / 2),
gamePhase: 'display',
}
case 'NEXT_CARD':
return { ...state, currentCardIndex: state.currentCardIndex + 1 }
case 'SHOW_INPUT_PHASE':
return { ...state, gamePhase: 'input' }
case 'ACCEPT_NUMBER': {
// 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,
}
}
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':
return { ...state, prefixAcceptanceTimeout: action.timeout }
case 'ADD_WRONG_GUESS_ANIMATION':
return {
...state,
wrongGuessAnimations: [
...state.wrongGuessAnimations,
{
number: action.number,
id: `wrong-${action.number}-${Date.now()}`,
timestamp: Date.now(),
},
],
}
case 'CLEAR_WRONG_GUESS_ANIMATIONS':
return {
...state,
wrongGuessAnimations: [],
}
case 'SHOW_RESULTS':
return { ...state, gamePhase: 'results' }
case 'RESET_QUIZ':
return {
...initialState,
cards: state.cards, // Preserve generated cards
displayTime: state.displayTime,
selectedCount: state.selectedCount,
selectedDifficulty: state.selectedDifficulty,
playMode: state.playMode, // Preserve play mode
// Preserve keyboard state across resets
hasPhysicalKeyboard: state.hasPhysicalKeyboard,
testingMode: state.testingMode,
showOnScreenKeyboard: state.showOnScreenKeyboard,
}
case 'SET_PHYSICAL_KEYBOARD':
return { ...state, hasPhysicalKeyboard: action.hasKeyboard }
case 'SET_TESTING_MODE':
return { ...state, testingMode: action.enabled }
case 'TOGGLE_ONSCREEN_KEYBOARD':
return { ...state, showOnScreenKeyboard: !state.showOnScreenKeyboard }
default:
return state
}
}

View File

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

View File

@@ -78,7 +78,7 @@ function ArcadeContent() {
function ArcadePageWithRedirect() {
return (
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizeGameContext={true}>
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizePlayerSelection={true}>
<ArcadeContent />
</PageWithNav>
)

View File

@@ -1,70 +1,219 @@
'use client'
import { useRoomData } from '@/hooks/useRoomData'
import { ModerationNotifications } from '@/components/nav/ModerationNotifications'
import { useRouter } from 'next/navigation'
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
import { MemoryQuizGame } from '../memory-quiz/components/MemoryQuizGame'
import { RoomMemoryQuizProvider } from '../memory-quiz/context/RoomMemoryQuizProvider'
import { GAMES_CONFIG } from '@/components/GameSelector'
import type { GameType } from '@/components/GameSelector'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../styled-system/css'
// Map GameType keys to internal game names
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
'battle-arena': 'matching',
'memory-quiz': 'memory-quiz',
'complement-race': 'complement-race',
'master-organizer': 'master-organizer',
}
/**
* /arcade/room - Renders the game for the user's current room
* Since users can only be in one room at a time, this is a simple singular route
*
* Shows game selection when no game is set, then shows the game itself once selected.
* URL never changes - it's always /arcade/room regardless of selection, setup, or gameplay.
*
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
* Instead, we show a friendly message with a link back to the Champion Arena.
*
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
* so we don't need to render it here.
*/
export default function RoomPage() {
const { roomData, isLoading, moderationEvent, clearModerationEvent } = useRoomData()
const router = useRouter()
const { roomData, isLoading } = useRoomData()
const { mutate: setRoomGame } = useSetRoomGame()
// Show loading state
if (isLoading) {
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Loading room...
</div>
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
</>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Loading room...
</div>
)
}
// Show error if no room (instead of redirecting)
if (!roomData) {
return (
<>
<div
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
gap: '1rem',
}}
>
<div>No active room found</div>
<a
href="/arcade"
style={{
color: '#3b82f6',
textDecoration: 'underline',
}}
>
Go to Champion Arena
</a>
</div>
)
}
// Show game selection if no game is set
if (!roomData.gameName) {
const handleGameSelect = (gameType: GameType) => {
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
const gameConfig = GAMES_CONFIG[gameType]
console.log('[RoomPage] Game config:', {
name: gameConfig.name,
available: gameConfig.available,
})
if (gameConfig.available === false) {
console.log('[RoomPage] Game not available, blocking selection')
return // Don't allow selecting unavailable games
}
// Map GameType to internal game name
const internalGameName = GAME_TYPE_TO_NAME[gameType]
console.log('[RoomPage] Mapping:', {
gameType,
internalGameName,
mappingExists: !!internalGameName,
})
console.log('[RoomPage] Calling setRoomGame with:', {
roomId: roomData.id,
gameName: internalGameName,
preservingGameConfig: true,
})
// Don't pass gameConfig - we want to preserve existing settings for all games
setRoomGame({
roomId: roomData.id,
gameName: internalGameName,
})
}
return (
<PageWithNav
navTitle="Choose Game"
navEmoji="🎮"
emphasizePlayerSelection={true}
onExitSession={() => router.push('/arcade')}
>
<div
className={css({
minHeight: '100vh',
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
gap: '1rem',
}}
padding: '4',
})}
>
<div>No active room found</div>
<a
href="/arcade"
style={{
color: '#3b82f6',
textDecoration: 'underline',
}}
<h1
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '8',
textAlign: 'center',
})}
>
Go to Champion Arena
</a>
Choose a Game
</h1>
<div
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
gap: '4',
maxWidth: '800px',
width: '100%',
})}
>
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => (
<button
key={gameType}
onClick={() => handleGameSelect(gameType as GameType)}
disabled={config.available === false}
className={css({
background: config.gradient,
border: '2px solid',
borderColor: config.borderColor || 'blue.200',
borderRadius: '2xl',
padding: '6',
cursor: config.available === false ? 'not-allowed' : 'pointer',
opacity: config.available === false ? 0.5 : 1,
transition: 'all 0.3s ease',
_hover:
config.available === false
? {}
: {
transform: 'translateY(-4px) scale(1.02)',
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
},
})}
>
<div
className={css({
fontSize: '4xl',
mb: '2',
})}
>
{config.icon}
</div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
mb: '2',
})}
>
{config.name}
</h3>
<p
className={css({
fontSize: 'sm',
color: 'gray.600',
})}
>
{config.description}
</p>
</button>
))}
</div>
</div>
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
</>
</PageWithNav>
)
}
@@ -72,21 +221,27 @@ export default function RoomPage() {
switch (roomData.gameName) {
case 'matching':
return (
<>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
<ModerationNotifications
moderationEvent={moderationEvent}
onClose={clearModerationEvent}
/>
</>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
)
// TODO: Add other games (complement-race, memory-quiz, etc.)
case 'memory-quiz':
return (
<RoomMemoryQuizProvider>
<MemoryQuizGame />
</RoomMemoryQuizProvider>
)
// TODO: Add other games (complement-race, etc.)
default:
return (
<>
<PageWithNav
navTitle="Game Not Available"
navEmoji="⚠️"
emphasizePlayerSelection={true}
onExitSession={() => router.push('/arcade')}
>
<div
style={{
display: 'flex',
@@ -99,11 +254,7 @@ export default function RoomPage() {
>
Game "{roomData.gameName}" not yet supported
</div>
<ModerationNotifications
moderationEvent={moderationEvent}
onClose={clearModerationEvent}
/>
</>
</PageWithNav>
)
}
}

View File

@@ -32,7 +32,7 @@ export function MemoryPairsGame() {
navTitle={navTitle}
navEmoji={navEmoji}
gameName="matching"
emphasizeGameContext={state.gamePhase === 'setup'}
emphasizePlayerSelection={state.gamePhase === 'setup'}
currentPlayerId={state.currentPlayer}
playerScores={state.scores}
playerStreaks={state.consecutiveMatches}

View File

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

View File

@@ -2,6 +2,7 @@
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { io } from 'socket.io-client'
import { useGetRoomByCode, useJoinRoom, useRoomData } from '@/hooks/useRoomData'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
@@ -276,21 +277,17 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
return
}
if (room.accessMode === 'restricted') {
setError('This room is invitation-only')
return
}
if (room.accessMode === 'approval-only') {
setShowApprovalPrompt(true)
return
}
// For restricted rooms, try to join - the API will check for invitation
// If user is in a different room, show confirmation
if (roomData) {
setShowConfirmation(true)
} else {
// Otherwise, auto-join (for open rooms)
// Otherwise, auto-join (for open rooms and restricted rooms with invitation)
handleJoin(room.id)
}
})
@@ -351,6 +348,60 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
}
}
// Socket listener for approval notifications
useEffect(() => {
if (!approvalRequested || !targetRoomData) return
console.log('[Join Page] Setting up approval listener for room:', targetRoomData.id)
let socket: ReturnType<typeof io> | null = null
// Fetch viewer ID and set up socket
const setupSocket = async () => {
try {
// Get current user's viewer ID
const res = await fetch('/api/viewer')
if (!res.ok) {
console.error('[Join Page] Failed to get viewer ID')
return
}
const { viewerId } = await res.json()
console.log('[Join Page] Got viewer ID:', viewerId)
// Connect socket
socket = io({ path: '/api/socket' })
socket.on('connect', () => {
console.log('[Join Page] Socket connected, joining user channel')
// Join user-specific channel to receive moderation events
socket?.emit('join-user-channel', { userId: viewerId })
})
socket.on('join-request-approved', (data: { roomId: string; requestId: string }) => {
console.log('[Join Page] Request approved via socket!', data)
if (data.roomId === targetRoomData.id) {
console.log('[Join Page] Joining room automatically...')
handleJoin(targetRoomData.id)
}
})
socket.on('connect_error', (error) => {
console.error('[Join Page] Socket connection error:', error)
})
} catch (error) {
console.error('[Join Page] Error setting up socket:', error)
}
}
setupSocket()
return () => {
console.log('[Join Page] Cleaning up approval listener')
socket?.disconnect()
}
}, [approvalRequested, targetRoomData, handleJoin])
// Only show error page for non-password and non-approval errors
if (error && !showPasswordPrompt && !showApprovalPrompt) {
return (

View File

@@ -3,6 +3,7 @@
import { AbacusDisplayProvider } from '@soroban/abacus-react'
import { QueryClientProvider } from '@tanstack/react-query'
import { type ReactNode, useState } from 'react'
import { ToastProvider } from '@/components/common/ToastContext'
import { FullscreenProvider } from '@/contexts/FullscreenContext'
import { GameModeProvider } from '@/contexts/GameModeContext'
import { UserProfileProvider } from '@/contexts/UserProfileContext'
@@ -20,17 +21,19 @@ export function ClientProviders({ children }: ClientProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
<AbacusDisplayProvider>
<AbacusSettingsSync />
<UserProfileProvider>
<GameModeProvider>
<FullscreenProvider>
{children}
<DeploymentInfo />
</FullscreenProvider>
</GameModeProvider>
</UserProfileProvider>
</AbacusDisplayProvider>
<ToastProvider>
<AbacusDisplayProvider>
<AbacusSettingsSync />
<UserProfileProvider>
<GameModeProvider>
<FullscreenProvider>
{children}
<DeploymentInfo />
</FullscreenProvider>
</GameModeProvider>
</UserProfileProvider>
</AbacusDisplayProvider>
</ToastProvider>
</QueryClientProvider>
)
}

View File

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

View File

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

@@ -13,7 +13,7 @@ interface PageWithNavProps {
navTitle?: string
navEmoji?: string
gameName?: 'matching' | 'memory-quiz' | 'complement-race' // Internal game name for API
emphasizeGameContext?: boolean
emphasizePlayerSelection?: boolean
onExitSession?: () => void
onSetup?: () => void
onNewGame?: () => void
@@ -28,7 +28,7 @@ export function PageWithNav({
navTitle,
navEmoji,
gameName,
emphasizeGameContext = false,
emphasizePlayerSelection = false,
onExitSession,
onSetup,
onNewGame,
@@ -103,7 +103,7 @@ export function PageWithNav({
? 'tournament'
: 'none'
const shouldEmphasize = emphasizeGameContext && mounted
const shouldEmphasize = emphasizePlayerSelection && mounted
const showFullscreenSelection = shouldEmphasize && activePlayerCount === 0
// Compute arcade session info for display

View File

@@ -0,0 +1,235 @@
'use client'
import * as Toast from '@radix-ui/react-toast'
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
export interface ToastMessage {
id: string
type: 'success' | 'error' | 'info'
title: string
description?: string
duration?: number
}
interface ToastContextValue {
showToast: (toast: Omit<ToastMessage, 'id'>) => void
showSuccess: (title: string, description?: string) => void
showError: (title: string, description?: string) => void
showInfo: (title: string, description?: string) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([])
const showToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
const id = Math.random().toString(36).substring(7)
setToasts((prev) => [...prev, { ...toast, id }])
}, [])
const showSuccess = useCallback(
(title: string, description?: string) => {
showToast({ type: 'success', title, description, duration: 5000 })
},
[showToast]
)
const showError = useCallback(
(title: string, description?: string) => {
showToast({ type: 'error', title, description, duration: 7000 })
},
[showToast]
)
const showInfo = useCallback(
(title: string, description?: string) => {
showToast({ type: 'info', title, description, duration: 5000 })
},
[showToast]
)
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const getToastStyles = (type: ToastMessage['type']) => {
switch (type) {
case 'success':
return {
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.97), rgba(22, 163, 74, 0.97))',
border: '2px solid rgba(34, 197, 94, 0.6)',
icon: '✓',
}
case 'error':
return {
background: 'linear-gradient(135deg, rgba(239, 68, 68, 0.97), rgba(220, 38, 38, 0.97))',
border: '2px solid rgba(239, 68, 68, 0.6)',
icon: '⚠',
}
case 'info':
return {
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.97), rgba(37, 99, 235, 0.97))',
border: '2px solid rgba(59, 130, 246, 0.6)',
icon: '',
}
}
}
return (
<ToastContext.Provider value={{ showToast, showSuccess, showError, showInfo }}>
{children}
<Toast.Provider swipeDirection="right">
{toasts.map((toast) => {
const styles = getToastStyles(toast.type)
return (
<Toast.Root
key={toast.id}
open={true}
onOpenChange={(open) => {
if (!open) {
removeToast(toast.id)
}
}}
duration={toast.duration}
style={{
background: styles.background,
border: styles.border,
borderRadius: '12px',
padding: '16px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
display: 'flex',
gap: '12px',
alignItems: 'flex-start',
minWidth: '300px',
maxWidth: '450px',
transition: 'all 0.2s ease',
}}
>
<div style={{ fontSize: '20px', flexShrink: 0 }}>{styles.icon}</div>
<div style={{ flex: 1 }}>
<Toast.Title
style={{
fontSize: '14px',
fontWeight: 'bold',
color: 'white',
marginBottom: toast.description ? '4px' : 0,
}}
>
{toast.title}
</Toast.Title>
{toast.description && (
<Toast.Description
style={{
fontSize: '13px',
color: 'rgba(255, 255, 255, 0.9)',
}}
>
{toast.description}
</Toast.Description>
)}
</div>
<Toast.Close
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'white',
fontSize: '16px',
lineHeight: 1,
flexShrink: 0,
}}
>
×
</Toast.Close>
</Toast.Root>
)
})}
<Toast.Viewport
style={{
position: 'fixed',
top: '80px',
right: '20px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
zIndex: 10001,
maxWidth: '100vw',
margin: 0,
listStyle: 'none',
outline: 'none',
}}
/>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes toastSlideIn {
from {
transform: translateX(calc(100% + 25px));
}
to {
transform: translateX(0);
}
}
@keyframes toastSlideOut {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(100% + 25px));
}
}
@keyframes toastHide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
[data-radix-toast-viewport] [data-state='open'] {
animation: toastSlideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-radix-toast-viewport] [data-state='closed'] {
animation: toastHide 100ms ease-in, toastSlideOut 200ms cubic-bezier(0.32, 0, 0.67, 0);
}
[data-radix-toast-viewport] [data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
[data-radix-toast-viewport] [data-swipe='cancel'] {
transform: translateX(0);
transition: transform 200ms ease-out;
}
[data-radix-toast-viewport] [data-swipe='end'] {
animation: toastSlideOut 100ms ease-out;
}
`,
}}
/>
</Toast.Provider>
</ToastContext.Provider>
)
}

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { useRouter } from 'next/navigation'
import { useToast } from '@/components/common/ToastContext'
import { InvitePlayersTab } from './InvitePlayersTab'
import { PlayOnlineTab } from './PlayOnlineTab'
import { addToRecentRooms } from './RecentRoomsList'
@@ -41,6 +42,7 @@ export function AddPlayerButton({
}: AddPlayerButtonProps) {
const popoverRef = React.useRef<HTMLDivElement>(null)
const router = useRouter()
const { showError } = useToast()
// Use lifted state if provided, otherwise fallback to internal state
const [internalShowPopover, setInternalShowPopover] = React.useState(false)
@@ -60,22 +62,29 @@ export function AddPlayerButton({
const { mutate: joinRoom } = useJoinRoom()
const { mutateAsync: getRoomByCode } = useGetRoomByCode()
// Handler for creating a new room
// Handler for creating a new room (without a game - game will be selected in room)
const handleCreateRoom = () => {
createRoom(
{
name: `${gameName} Room`,
gameName: gameName,
name: null, // Auto-generated from code
gameName: null, // No game selected yet - will be chosen in room
creatorName: 'Player',
},
{
onSuccess: (data) => {
// Popover stays open, switch to invite tab to share room code
setActiveTab('invite')
// Add to recent rooms
addToRecentRooms({
code: data.code,
name: data.name,
gameName: data.gameName,
})
// Close popover and navigate to room to choose game
setShowPopover(false)
router.push('/arcade/room')
},
onError: (error) => {
console.error('Failed to create room:', error)
alert(`Failed to create room: ${error.message}`)
showError('Failed to create room', error.message)
},
}
)
@@ -100,8 +109,9 @@ export function AddPlayerButton({
gameName: data.room.gameName,
})
}
// Close popover
// Close popover and navigate to room
setShowPopover(false)
router.push('/arcade/room')
},
}
)

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { io } from 'socket.io-client'
import { Modal } from '@/components/common/Modal'
import { useRoomData } from '@/hooks/useRoomData'
import type { schema } from '@/db'
import { useRoomData } from '@/hooks/useRoomData'
export interface JoinRoomModalProps {
/**
@@ -32,6 +33,7 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
const [roomInfo, setRoomInfo] = useState<schema.ArcadeRoom | null>(null)
const [needsPassword, setNeedsPassword] = useState(false)
const [needsApproval, setNeedsApproval] = useState(false)
const [approvalRequested, setApprovalRequested] = useState(false)
const handleClose = () => {
setCode('')
@@ -41,6 +43,7 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
setRoomInfo(null)
setNeedsPassword(false)
setNeedsApproval(false)
setApprovalRequested(false)
onClose()
}
@@ -74,18 +77,14 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
return
}
if (room.accessMode === 'restricted') {
setError('This room is invitation-only. Please ask the host for an invitation.')
setIsLoading(false)
return
}
if (room.accessMode === 'approval-only') {
setNeedsApproval(true)
setIsLoading(false)
return
}
// For restricted rooms, try to join - the API will check for invitation
if (room.accessMode === 'password') {
// Check if password is provided
if (!needsPassword) {
@@ -118,6 +117,8 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
if (!roomInfo) return
setIsLoading(true)
setError('')
try {
const res = await fetch(`/api/arcade/rooms/${roomInfo.id}/join-requests`, {
method: 'POST',
@@ -129,16 +130,76 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
throw new Error(errorData.error || 'Failed to request access')
}
// Success!
alert('Access request sent! The host will review your request.')
handleClose()
// Success! Show waiting state
setApprovalRequested(true)
setIsLoading(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to request access')
} finally {
setIsLoading(false)
}
}
// Socket listener for approval notifications
useEffect(() => {
if (!approvalRequested || !roomInfo) return
console.log('[JoinRoomModal] Setting up approval listener for room:', roomInfo.id)
let socket: ReturnType<typeof io> | null = null
// Fetch viewer ID and set up socket
const setupSocket = async () => {
try {
// Get current user's viewer ID
const res = await fetch('/api/viewer')
if (!res.ok) {
console.error('[JoinRoomModal] Failed to get viewer ID')
return
}
const { viewerId } = await res.json()
console.log('[JoinRoomModal] Got viewer ID:', viewerId)
// Connect socket
socket = io({ path: '/api/socket' })
socket.on('connect', () => {
console.log('[JoinRoomModal] Socket connected, joining user channel')
// Join user-specific channel to receive moderation events
socket?.emit('join-user-channel', { userId: viewerId })
})
socket.on('join-request-approved', async (data: { roomId: string; requestId: string }) => {
console.log('[JoinRoomModal] Request approved via socket!', data)
if (data.roomId === roomInfo.id) {
console.log('[JoinRoomModal] Joining room automatically...')
try {
await joinRoom(roomInfo.id)
handleClose()
onSuccess?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join room')
setIsLoading(false)
}
}
})
socket.on('connect_error', (error) => {
console.error('[JoinRoomModal] Socket connection error:', error)
})
} catch (error) {
console.error('[JoinRoomModal] Error setting up socket:', error)
}
}
setupSocket()
return () => {
console.log('[JoinRoomModal] Cleaning up approval listener')
socket?.disconnect()
}
}, [approvalRequested, roomInfo, joinRoom, handleClose, onSuccess])
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<div
@@ -165,7 +226,9 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
}}
>
{needsApproval
? 'This room requires host approval. Send a request to join?'
? approvalRequested
? 'Your request has been sent to the room moderator.'
: 'This room requires host approval. Send a request to join?'
: needsPassword
? 'This room is password protected'
: 'Enter the 6-character room code'}
@@ -174,110 +237,192 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
{needsApproval ? (
// Approval request UI
<div>
<div
style={{
padding: '16px',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
marginBottom: '20px',
}}
>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 1)',
marginBottom: '8px',
}}
>
<strong>{roomInfo?.name}</strong>
</p>
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
Code: {roomInfo?.code}
</p>
</div>
{approvalRequested ? (
// Waiting for approval state
<>
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<h3
style={{
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '8px',
color: 'rgba(96, 165, 250, 1)',
}}
>
Waiting for Approval
</h3>
</div>
{error && (
<p
style={{
fontSize: '13px',
color: 'rgba(248, 113, 113, 1)',
marginBottom: '16px',
textAlign: 'center',
}}
>
{error}
</p>
)}
<div
style={{
padding: '16px',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
marginBottom: '20px',
}}
>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 1)',
marginBottom: '8px',
}}
>
<strong>{roomInfo?.name}</strong>
</p>
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
Code: {roomInfo?.code}
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
type="button"
onClick={handleClose}
disabled={isLoading}
style={{
flex: 1,
padding: '12px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
<p
style={{
fontSize: '13px',
color: 'rgba(156, 163, 175, 1)',
textAlign: 'center',
marginBottom: '20px',
}}
>
You'll be able to join once the host approves your request. You can close this
dialog and check back later.
</p>
<button
type="button"
onClick={handleClose}
style={{
width: '100%',
padding: '12px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
>
Cancel
</button>
<button
type="button"
onClick={handleRequestAccess}
disabled={isLoading}
style={{
flex: 1,
padding: '12px',
background: isLoading
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: isLoading ? 'rgba(156, 163, 175, 1)' : 'rgba(255, 255, 255, 1)',
border: isLoading
? '2px solid rgba(75, 85, 99, 0.5)'
: '2px solid rgba(59, 130, 246, 0.6)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
}
}}
>
{isLoading ? 'Sending...' : 'Send Request'}
</button>
</div>
}}
>
Close
</button>
</>
) : (
// Initial request prompt
<>
<div
style={{
padding: '16px',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
marginBottom: '20px',
}}
>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 1)',
marginBottom: '8px',
}}
>
<strong>{roomInfo?.name}</strong>
</p>
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
Code: {roomInfo?.code}
</p>
</div>
{error && (
<p
style={{
fontSize: '13px',
color: 'rgba(248, 113, 113, 1)',
marginBottom: '16px',
textAlign: 'center',
}}
>
{error}
</p>
)}
<div style={{ display: 'flex', gap: '12px' }}>
<button
type="button"
onClick={handleClose}
disabled={isLoading}
style={{
flex: 1,
padding: '12px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
>
Cancel
</button>
<button
type="button"
onClick={handleRequestAccess}
disabled={isLoading}
style={{
flex: 1,
padding: '12px',
background: isLoading
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: isLoading ? 'rgba(156, 163, 175, 1)' : 'rgba(255, 255, 255, 1)',
border: isLoading
? '2px solid rgba(75, 85, 99, 0.5)'
: '2px solid rgba(59, 130, 246, 0.6)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
}
}}
>
{isLoading ? 'Sending...' : 'Send Request'}
</button>
</div>
</>
)}
</div>
) : (
// Standard join form
@@ -323,7 +468,6 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
}}
placeholder="Enter password"
disabled={isLoading}
autoFocus
style={{
width: '100%',
padding: '14px',

View File

@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import * as Toast from '@radix-ui/react-toast'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { useToast } from '@/components/common/ToastContext'
import type { ModerationEvent } from '@/hooks/useRoomData'
import { useJoinRoom } from '@/hooks/useRoomData'
@@ -25,8 +27,13 @@ export function ModerationNotifications({
onClose,
}: ModerationNotificationsProps) {
const router = useRouter()
const queryClient = useQueryClient()
const { showError } = useToast()
const [showToast, setShowToast] = useState(false)
const [showJoinRequestToast, setShowJoinRequestToast] = useState(false)
const [isAcceptingInvitation, setIsAcceptingInvitation] = useState(false)
const [isProcessingRequest, setIsProcessingRequest] = useState(false)
const [requestError, setRequestError] = useState<string | null>(null)
const { mutateAsync: joinRoom } = useJoinRoom()
// Handle report toast (for hosts)
@@ -42,8 +49,94 @@ export function ModerationNotifications({
}
}, [moderationEvent, onClose])
// Handle join request toast (for hosts)
useEffect(() => {
if (moderationEvent?.type === 'join-request') {
setShowJoinRequestToast(true)
setRequestError(null) // Clear any previous errors
} else {
// Reset toast state when event is cleared or changes type
setShowJoinRequestToast(false)
setRequestError(null)
}
}, [moderationEvent])
// Handle approve join request
const handleApprove = async () => {
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
setIsProcessingRequest(true)
setRequestError(null) // Clear any previous errors
try {
const response = await fetch(
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/approve`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to approve join request')
}
// Close toast and event on success
setShowJoinRequestToast(false)
onClose()
// Invalidate join requests query to refresh the list
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
} catch (error) {
console.error('Failed to approve join request:', error)
// Keep toast visible and show error message
setRequestError(error instanceof Error ? error.message : 'Failed to approve request')
} finally {
setIsProcessingRequest(false)
}
}
// Handle deny join request
const handleDeny = async () => {
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
setIsProcessingRequest(true)
setRequestError(null) // Clear any previous errors
try {
const response = await fetch(
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/deny`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to deny join request')
}
// Close toast and event on success
setShowJoinRequestToast(false)
onClose()
// Invalidate join requests query to refresh the list
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
} catch (error) {
console.error('Failed to deny join request:', error)
// Keep toast visible and show error message
setRequestError(error instanceof Error ? error.message : 'Failed to deny request')
} finally {
setIsProcessingRequest(false)
}
}
// Kicked modal
if (moderationEvent?.type === 'kicked') {
const isRetired = moderationEvent.data.reason?.includes('retired')
return (
<Modal isOpen={true} onClose={() => {}}>
<div
@@ -54,7 +147,7 @@ export function ModerationNotifications({
minWidth: '400px',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{isRetired ? '🏁' : '⚠️'}</div>
<h2
style={{
fontSize: '24px',
@@ -63,7 +156,7 @@ export function ModerationNotifications({
color: 'rgba(253, 186, 116, 1)',
}}
>
Kicked from Room
{isRetired ? 'Room Retired' : 'Kicked from Room'}
</h2>
<p
style={{
@@ -72,10 +165,16 @@ export function ModerationNotifications({
marginBottom: '8px',
}}
>
You were kicked from the room by{' '}
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
{moderationEvent.data.kickedBy}
</strong>
{isRetired ? (
<>The room owner has retired this room and access has been closed</>
) : (
<>
You were kicked from the room by{' '}
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
{moderationEvent.data.kickedBy}
</strong>
</>
)}
</p>
<p
style={{
@@ -84,7 +183,9 @@ export function ModerationNotifications({
marginBottom: '24px',
}}
>
You can rejoin if the host sends you a new invite
{isRetired
? 'Only the room owner can access retired rooms'
: 'You can rejoin if the host sends you a new invite'}
</p>
<button
@@ -391,6 +492,255 @@ export function ModerationNotifications({
)
}
// Join request toast (for hosts)
if (moderationEvent?.type === 'join-request') {
return (
<Toast.Provider swipeDirection="right" duration={Infinity}>
<Toast.Root
open={showJoinRequestToast}
onOpenChange={(open) => {
if (!open) {
setShowJoinRequestToast(false)
onClose()
}
}}
style={{
background:
'linear-gradient(135deg, rgba(59, 130, 246, 0.97), rgba(37, 99, 235, 0.97))',
border: '2px solid rgba(59, 130, 246, 0.6)',
borderRadius: '12px',
padding: '16px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
display: 'flex',
gap: '12px',
alignItems: 'flex-start',
minWidth: '350px',
maxWidth: '450px',
transition: 'all 0.2s ease',
}}
>
<div style={{ fontSize: '24px', flexShrink: 0 }}></div>
<div style={{ flex: 1 }}>
<Toast.Title
style={{
fontSize: '15px',
fontWeight: 'bold',
color: 'white',
marginBottom: '4px',
}}
>
Join Request
</Toast.Title>
<Toast.Description
style={{
fontSize: '13px',
color: 'rgba(255, 255, 255, 0.9)',
marginBottom: '12px',
}}
>
<strong>{moderationEvent.data.requesterName}</strong> wants to join your room
</Toast.Description>
{/* Error message */}
{requestError && (
<div
style={{
padding: '8px 10px',
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid rgba(239, 68, 68, 0.4)',
borderRadius: '6px',
marginBottom: '12px',
}}
>
<div
style={{
fontSize: '12px',
color: 'rgba(254, 202, 202, 1)',
fontWeight: '600',
marginBottom: '2px',
}}
>
Error
</div>
<div
style={{
fontSize: '11px',
color: 'rgba(255, 255, 255, 0.9)',
}}
>
{requestError}
</div>
</div>
)}
{/* Action buttons */}
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
disabled={isProcessingRequest}
onClick={(e) => {
e.stopPropagation()
handleDeny()
}}
style={{
flex: 1,
padding: '8px 12px',
background: isProcessingRequest
? 'rgba(75, 85, 99, 0.3)'
: 'rgba(255, 255, 255, 0.2)',
color: 'white',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '8px',
fontSize: '13px',
fontWeight: '600',
cursor: isProcessingRequest ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: isProcessingRequest ? 0.5 : 1,
}}
onMouseEnter={(e) => {
if (!isProcessingRequest) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)'
}
}}
onMouseLeave={(e) => {
if (!isProcessingRequest) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)'
}
}}
>
Deny
</button>
<button
type="button"
disabled={isProcessingRequest}
onClick={(e) => {
e.stopPropagation()
handleApprove()
}}
style={{
flex: 1,
padding: '8px 12px',
background: isProcessingRequest
? 'rgba(75, 85, 99, 0.3)'
: 'rgba(34, 197, 94, 0.9)',
color: 'white',
border: '1px solid rgba(34, 197, 94, 0.8)',
borderRadius: '8px',
fontSize: '13px',
fontWeight: '600',
cursor: isProcessingRequest ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: isProcessingRequest ? 0.5 : 1,
}}
onMouseEnter={(e) => {
if (!isProcessingRequest) {
e.currentTarget.style.background = 'rgba(34, 197, 94, 1)'
}
}}
onMouseLeave={(e) => {
if (!isProcessingRequest) {
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.9)'
}
}}
>
{isProcessingRequest ? 'Processing...' : 'Approve'}
</button>
</div>
</div>
<Toast.Close
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'white',
fontSize: '16px',
lineHeight: 1,
flexShrink: 0,
}}
>
×
</Toast.Close>
</Toast.Root>
<Toast.Viewport
style={{
position: 'fixed',
top: '80px',
right: '20px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
zIndex: 10001,
maxWidth: '100vw',
margin: 0,
listStyle: 'none',
outline: 'none',
}}
/>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes slideIn {
from {
transform: translateX(calc(100% + 25px));
}
to {
transform: translateX(0);
}
}
@keyframes slideOut {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(100% + 25px));
}
}
@keyframes hide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
[data-state='open'] {
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-state='closed'] {
animation: hide 100ms ease-in, slideOut 200ms cubic-bezier(0.32, 0, 0.67, 0);
}
[data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
[data-swipe='cancel'] {
transform: translateX(0);
transition: transform 200ms ease-out;
}
[data-swipe='end'] {
animation: slideOut 100ms ease-out;
}
`,
}}
/>
</Toast.Provider>
)
}
// Invitation modal
if (moderationEvent?.type === 'invitation') {
const invitationType = moderationEvent.data.invitationType
@@ -486,7 +836,10 @@ export function ModerationNotifications({
router.push('/arcade/room')
} catch (error) {
console.error('Failed to join room:', error)
alert(error instanceof Error ? error.message : 'Failed to join room')
showError(
'Failed to join room',
error instanceof Error ? error.message : undefined
)
setIsAcceptingInvitation(false)
}
}}

View File

@@ -88,10 +88,16 @@ export function ModerationPanel({
// Settings state
const [accessMode, setAccessMode] = useState<string>('open')
const [originalAccessMode, setOriginalAccessMode] = useState<string>('open')
const [roomPassword, setRoomPassword] = useState('')
const [showPasswordInput, setShowPasswordInput] = useState(false)
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('')
const [joinRequests, setJoinRequests] = useState<any[]>([])
const [passwordCopied, setPasswordCopied] = useState(false)
// Inline feedback state
const [successMessage, setSuccessMessage] = useState<string>('')
const [errorMessage, setErrorMessage] = useState<string>('')
// Ban modal state
const [showBanModal, setShowBanModal] = useState(false)
@@ -180,8 +186,9 @@ export function ModerationPanel({
}
// Success - member will be removed via socket update
showSuccess('Player kicked from room')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to kick player')
showError(err instanceof Error ? err.message : 'Failed to kick player')
} finally {
setActionLoading(null)
}
@@ -218,8 +225,10 @@ export function ModerationPanel({
const data = await bansRes.json()
setBans(data.bans || [])
}
showSuccess('Player banned from room')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to ban player')
showError(err instanceof Error ? err.message : 'Failed to ban player')
} finally {
setActionLoading(null)
setBanTargetUserId(null)
@@ -255,8 +264,10 @@ export function ModerationPanel({
const data = await historyRes.json()
setHistoricalMembers(data.historicalMembers || [])
}
showSuccess('Player unbanned')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to unban player')
showError(err instanceof Error ? err.message : 'Failed to unban player')
} finally {
setActionLoading(null)
}
@@ -292,9 +303,9 @@ export function ModerationPanel({
setHistoricalMembers(data.historicalMembers || [])
}
alert(`${userName} has been unbanned and invited back to the room!`)
showSuccess(`${userName} has been unbanned and invited back to the room`)
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to unban player')
showError(err instanceof Error ? err.message : 'Failed to unban player')
} finally {
setActionLoading(null)
}
@@ -322,9 +333,9 @@ export function ModerationPanel({
setHistoricalMembers(data.historicalMembers || [])
}
alert(`Invitation sent to ${userName}!`)
showSuccess(`Invitation sent to ${userName}`)
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to send invitation')
showError(err instanceof Error ? err.message : 'Failed to send invitation')
} finally {
setActionLoading(null)
}
@@ -336,11 +347,19 @@ export function ModerationPanel({
const loadSettings = async () => {
try {
// Fetch current room data to get access mode
// Fetch current room data to get access mode and password
const roomRes = await fetch(`/api/arcade/rooms/${roomId}`)
if (roomRes.ok) {
const data = await roomRes.json()
setAccessMode(data.room?.accessMode || 'open')
const currentAccessMode = data.room?.accessMode || 'open'
setAccessMode(currentAccessMode)
setOriginalAccessMode(currentAccessMode)
// Set password field if room has a password and user is the creator
if (currentAccessMode === 'password' && data.room?.displayPassword) {
setRoomPassword(data.room.displayPassword)
setShowPasswordInput(true)
}
}
// Fetch join requests if any
@@ -377,11 +396,12 @@ export function ModerationPanel({
throw new Error(errorData.error || 'Failed to update settings')
}
alert('Room settings updated successfully!')
showSuccess('Room settings updated successfully')
setOriginalAccessMode(accessMode) // Update original to current
setShowPasswordInput(false)
setRoomPassword('')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to update settings')
showError(err instanceof Error ? err.message : 'Failed to update settings')
} finally {
setActionLoading(null)
}
@@ -409,10 +429,10 @@ export function ModerationPanel({
throw new Error(errorData.error || 'Failed to transfer ownership')
}
alert(`Ownership transferred to ${newOwner.displayName}!`)
onClose() // Close panel since user is no longer host
showSuccess(`Ownership transferred to ${newOwner.displayName}`)
setTimeout(() => onClose(), 2000) // Close panel after showing message
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to transfer ownership')
showError(err instanceof Error ? err.message : 'Failed to transfer ownership')
} finally {
setActionLoading(null)
}
@@ -437,9 +457,9 @@ export function ModerationPanel({
setJoinRequests(data.requests || [])
}
alert('Join request approved!')
showSuccess('Join request approved')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to approve request')
showError(err instanceof Error ? err.message : 'Failed to approve request')
} finally {
setActionLoading(null)
}
@@ -463,16 +483,47 @@ export function ModerationPanel({
const data = await requestsRes.json()
setJoinRequests(data.requests || [])
}
showSuccess('Join request denied')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to deny request')
showError(err instanceof Error ? err.message : 'Failed to deny request')
} finally {
setActionLoading(null)
}
}
const handleCopyPassword = async () => {
if (!roomPassword) return
try {
await navigator.clipboard.writeText(roomPassword)
setPasswordCopied(true)
setTimeout(() => setPasswordCopied(false), 2000)
} catch (err) {
console.error('Failed to copy password:', err)
showError('Failed to copy password to clipboard')
}
}
// Utility functions for showing feedback
const showSuccess = (message: string) => {
setSuccessMessage(message)
setErrorMessage('')
setTimeout(() => setSuccessMessage(''), 5000)
}
const showError = (message: string) => {
setErrorMessage(message)
setSuccessMessage('')
setTimeout(() => setErrorMessage(''), 5000)
}
const pendingReports = reports.filter((r) => r.status === 'pending')
const otherMembers = members.filter((m) => m.userId !== currentUserId)
// Check if there are unsaved changes in settings
const hasUnsavedAccessModeChanges = accessMode !== originalAccessMode
// Group reports by reported user ID
const reportsByUser = pendingReports.reduce(
(acc, report) => {
@@ -513,6 +564,69 @@ export function ModerationPanel({
Manage members, reports, and bans
</p>
{/* Success/Error Messages */}
{(successMessage || errorMessage) && (
<div
style={{
padding: '12px 16px',
background: successMessage ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
border: successMessage
? '1px solid rgba(34, 197, 94, 0.4)'
: '1px solid rgba(239, 68, 68, 0.4)',
borderRadius: '8px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
animation: 'fadeIn 0.2s ease',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flex: 1,
}}
>
<span style={{ fontSize: '16px' }}>{successMessage ? '✓' : '⚠'}</span>
<span
style={{
fontSize: '14px',
fontWeight: '600',
color: successMessage ? 'rgba(34, 197, 94, 1)' : 'rgba(239, 68, 68, 1)',
}}
>
{successMessage || errorMessage}
</span>
</div>
<button
type="button"
onClick={() => {
setSuccessMessage('')
setErrorMessage('')
}}
style={{
background: 'none',
border: 'none',
color: successMessage ? 'rgba(34, 197, 94, 0.8)' : 'rgba(239, 68, 68, 0.8)',
fontSize: '18px',
cursor: 'pointer',
padding: '0 4px',
lineHeight: 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.8'
}}
>
</button>
</div>
)}
{/* Tabs */}
<div
style={{
@@ -1333,77 +1447,203 @@ export function ModerationPanel({
borderRadius: '8px',
}}
>
<select
value={accessMode}
onChange={(e) => {
setAccessMode(e.target.value)
setShowPasswordInput(e.target.value === 'password')
}}
{/* Access mode button grid */}
<div
style={{
width: '100%',
padding: '10px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(75, 85, 99, 0.5)',
borderRadius: '6px',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
marginBottom: '12px',
cursor: 'pointer',
}}
>
<option value="open">🌐 Open - Anyone can join</option>
<option value="password">🔑 Password Protected</option>
<option value="approval-only"> Approval Required</option>
<option value="restricted">🚫 Restricted - Invitation only</option>
<option value="locked">🔒 Locked - No new members</option>
<option value="retired">🏁 Retired - Room closed</option>
</select>
{[
{ value: 'open', emoji: '🌐', label: 'Open', desc: 'Anyone' },
{ value: 'password', emoji: '🔑', label: 'Password', desc: 'With key' },
{ value: 'approval-only', emoji: '✋', label: 'Approval', desc: 'Request' },
{
value: 'restricted',
emoji: '🚫',
label: 'Restricted',
desc: 'Invite only',
},
{ value: 'locked', emoji: '🔒', label: 'Locked', desc: 'No new members' },
{ value: 'retired', emoji: '🏁', label: 'Retired', desc: 'Closed' },
].map((mode) => (
<button
key={mode.value}
type="button"
disabled={actionLoading === 'update-settings'}
onClick={() => {
setAccessMode(mode.value)
setShowPasswordInput(mode.value === 'password')
}}
style={{
padding: '10px 12px',
background:
accessMode === mode.value
? 'rgba(253, 186, 116, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
border:
accessMode === mode.value
? '2px solid rgba(253, 186, 116, 0.6)'
: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '8px',
color:
accessMode === mode.value
? 'rgba(253, 186, 116, 1)'
: 'rgba(209, 213, 219, 0.8)',
fontSize: '13px',
fontWeight: '500',
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => {
if (actionLoading !== 'update-settings' && accessMode !== mode.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
e.currentTarget.style.borderColor = 'rgba(253, 186, 116, 0.4)'
}
}}
onMouseLeave={(e) => {
if (accessMode !== mode.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
}
}}
>
<span style={{ fontSize: '18px' }}>{mode.emoji}</span>
<div style={{ textAlign: 'left', flex: 1, lineHeight: '1.2' }}>
<div style={{ fontSize: '13px', fontWeight: '600' }}>{mode.label}</div>
<div style={{ fontSize: '11px', opacity: 0.7 }}>{mode.desc}</div>
</div>
</button>
))}
</div>
{/* Password input (conditional) */}
{(accessMode === 'password' || showPasswordInput) && (
<input
type="text"
value={roomPassword}
onChange={(e) => setRoomPassword(e.target.value)}
placeholder="Enter room password"
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: '600',
color: 'rgba(209, 213, 219, 0.8)',
marginBottom: '6px',
}}
>
Room Password
</label>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={roomPassword}
onChange={(e) => setRoomPassword(e.target.value)}
placeholder="Enter password to share with guests"
style={{
flex: 1,
padding: '10px 12px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(75, 85, 99, 0.5)',
borderRadius: '6px',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
outline: 'none',
transition: 'border-color 0.2s ease',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(253, 186, 116, 0.6)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
}}
/>
<button
type="button"
onClick={handleCopyPassword}
disabled={!roomPassword}
title="Copy password to clipboard"
style={{
padding: '10px 16px',
background: passwordCopied
? 'rgba(34, 197, 94, 0.2)'
: roomPassword
? 'rgba(59, 130, 246, 0.2)'
: 'rgba(75, 85, 99, 0.2)',
color: passwordCopied
? 'rgba(34, 197, 94, 1)'
: roomPassword
? 'rgba(59, 130, 246, 1)'
: 'rgba(156, 163, 175, 1)',
border: passwordCopied
? '1px solid rgba(34, 197, 94, 0.4)'
: roomPassword
? '1px solid rgba(59, 130, 246, 0.4)'
: '1px solid rgba(75, 85, 99, 0.3)',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: roomPassword ? 'pointer' : 'not-allowed',
opacity: roomPassword ? 1 : 0.5,
transition: 'all 0.2s ease',
whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => {
if (roomPassword && !passwordCopied) {
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.3)'
}
}}
onMouseLeave={(e) => {
if (roomPassword && !passwordCopied) {
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)'
}
}}
>
{passwordCopied ? '✓ Copied!' : '📋 Copy'}
</button>
</div>
<div
style={{
fontSize: '11px',
color: 'rgba(156, 163, 175, 1)',
marginTop: '4px',
}}
>
Share this password with guests to allow them to join
</div>
</div>
)}
{hasUnsavedAccessModeChanges && (
<button
type="button"
onClick={handleUpdateAccessMode}
disabled={actionLoading === 'update-settings'}
style={{
width: '100%',
padding: '10px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(75, 85, 99, 0.5)',
background:
actionLoading === 'update-settings'
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: 'white',
border:
actionLoading === 'update-settings'
? '1px solid rgba(75, 85, 99, 0.5)'
: '1px solid rgba(59, 130, 246, 0.6)',
borderRadius: '6px',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
marginBottom: '12px',
fontWeight: '600',
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
}}
/>
>
{actionLoading === 'update-settings' ? 'Updating...' : 'Update Access Mode'}
</button>
)}
<button
type="button"
onClick={handleUpdateAccessMode}
disabled={actionLoading === 'update-settings'}
style={{
width: '100%',
padding: '10px',
background:
actionLoading === 'update-settings'
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: 'white',
border:
actionLoading === 'update-settings'
? '1px solid rgba(75, 85, 99, 0.5)'
: '1px solid rgba(59, 130, 246, 0.6)',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
}}
>
{actionLoading === 'update-settings' ? 'Updating...' : 'Update Access Mode'}
</button>
</div>
</div>
@@ -1613,23 +1853,44 @@ export function ModerationPanel({
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '20px' }}>
<button
type="button"
onClick={onClose}
onClick={hasUnsavedAccessModeChanges ? undefined : onClose}
disabled={hasUnsavedAccessModeChanges}
title={
hasUnsavedAccessModeChanges
? 'Please update access mode settings before closing'
: undefined
}
style={{
padding: '10px 20px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '1px solid rgba(75, 85, 99, 0.5)',
background: hasUnsavedAccessModeChanges
? 'rgba(75, 85, 99, 0.2)'
: 'rgba(75, 85, 99, 0.3)',
color: hasUnsavedAccessModeChanges
? 'rgba(156, 163, 175, 1)'
: 'rgba(209, 213, 219, 1)',
border: hasUnsavedAccessModeChanges
? '1px solid rgba(251, 146, 60, 0.4)'
: '1px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
cursor: hasUnsavedAccessModeChanges ? 'not-allowed' : 'pointer',
opacity: hasUnsavedAccessModeChanges ? 0.6 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
if (!hasUnsavedAccessModeChanges) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
} else {
e.currentTarget.style.borderColor = 'rgba(251, 146, 60, 0.8)'
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
if (!hasUnsavedAccessModeChanges) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
} else {
e.currentTarget.style.borderColor = 'rgba(251, 146, 60, 0.4)'
}
}}
>
Close

View File

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

View File

@@ -1,5 +1,6 @@
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useToast } from '@/components/common/ToastContext'
import { useJoinRoom } from '@/hooks/useRoomData'
interface PendingInvitation {
@@ -32,6 +33,7 @@ export interface PendingInvitationsProps {
*/
export function PendingInvitations({ onInvitationChange, currentRoomId }: PendingInvitationsProps) {
const router = useRouter()
const { showError } = useToast()
const [invitations, setInvitations] = useState<PendingInvitation[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
@@ -72,7 +74,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
onInvitationChange?.()
} catch (error) {
console.error('Failed to join room:', error)
alert(error instanceof Error ? error.message : 'Failed to join room')
showError('Failed to join room', error instanceof Error ? error.message : undefined)
} finally {
setActionLoading(null)
}
@@ -97,7 +99,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
onInvitationChange?.()
} catch (error) {
console.error('Failed to decline invitation:', error)
alert(error instanceof Error ? error.message : 'Failed to decline invitation')
showError('Failed to decline invitation', error instanceof Error ? error.message : undefined)
} finally {
setActionLoading(null)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { EmojiPicker } from '../../app/games/matching/components/EmojiPicker'
import { useGameMode } from '../../contexts/GameModeContext'
import { generateUniquePlayerName } from '../../utils/playerNames'
interface PlayerConfigDialogProps {
playerId: string
@@ -48,6 +49,15 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
setShowEmojiPicker(false)
}
const handleGenerateNewName = () => {
const allPlayers = Array.from(players.values())
const existingNames = allPlayers.filter((p) => p.id !== playerId).map((p) => p.name)
const newName = generateUniquePlayerName(existingNames)
setLocalName(newName)
updatePlayer(playerId, { name: newName })
}
// Get player number for UI theming (first 4 players get special colors)
const allPlayers = Array.from(players.values()).sort((a, b) => {
const aTime =
@@ -256,40 +266,89 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
>
Name
</label>
<input
type="text"
value={localName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Player Name"
maxLength={20}
style={{
width: '100%',
padding: '12px 16px',
fontSize: '16px',
border: '2px solid #e5e7eb',
borderRadius: '12px',
outline: 'none',
transition: 'all 0.2s ease',
fontWeight: '500',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = gradientColor
e.currentTarget.style.boxShadow = `0 0 0 3px ${gradientColor}20`
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#e5e7eb'
e.currentTarget.style.boxShadow = 'none'
}}
/>
<div
style={{
fontSize: '12px',
color: '#6b7280',
marginTop: '4px',
textAlign: 'right',
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
}}
>
{localName.length}/20 characters
<div style={{ flex: 1 }}>
<input
type="text"
value={localName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Player Name"
maxLength={20}
style={{
width: '100%',
padding: '12px 16px',
fontSize: '16px',
border: '2px solid #e5e7eb',
borderRadius: '12px',
outline: 'none',
transition: 'all 0.2s ease',
fontWeight: '500',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = gradientColor
e.currentTarget.style.boxShadow = `0 0 0 3px ${gradientColor}20`
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#e5e7eb'
e.currentTarget.style.boxShadow = 'none'
}}
/>
<div
style={{
fontSize: '12px',
color: '#6b7280',
marginTop: '6px',
}}
>
{localName.length}/20 characters
</div>
</div>
<div style={{ flexShrink: 0 }}>
<button
type="button"
onClick={handleGenerateNewName}
style={{
padding: '12px 16px',
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
border: 'none',
borderRadius: '12px',
color: 'white',
fontSize: '20px',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)'
e.currentTarget.style.boxShadow = `0 4px 12px ${gradientColor}40`
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.boxShadow = 'none'
}}
title="Generate random name"
>
🎲
</button>
<div
style={{
fontSize: '12px',
color: '#6b7280',
marginTop: '6px',
textAlign: 'center',
}}
>
Random name
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
import { useClearRoomGame, useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
import { CreateRoomModal } from './CreateRoomModal'
@@ -62,6 +62,7 @@ export function RoomInfo({
const { getRoomShareUrl, roomData } = useRoomData()
const { data: currentUserId } = useViewerId()
const { mutateAsync: leaveRoom } = useLeaveRoom()
const { mutate: clearRoomGame } = useClearRoomGame()
// Use room display utility for consistent naming
const displayName = joinCode
@@ -403,6 +404,43 @@ export function RoomInfo({
</DropdownMenu.Item>
)}
{/* Change Game - only show for host and only when a game is selected */}
{isCurrentUserCreator && roomId && roomData?.gameName && (
<DropdownMenu.Item
onSelect={() => {
if (roomId) {
clearRoomGame(roomId)
}
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
borderRadius: '8px',
border: 'none',
background: 'transparent',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(236, 72, 153, 0.2)'
e.currentTarget.style.color = 'rgba(249, 168, 212, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
}}
>
<span style={{ fontSize: '16px' }}>🔄</span>
<span>Change Game</span>
</DropdownMenu.Item>
)}
{/* Moderation - only show for host */}
{isCurrentUserCreator && roomId && (
<DropdownMenu.Item

View File

@@ -1,5 +1,8 @@
// Available character emojis for players
export const PLAYER_EMOJIS = [
// Abacus
'🧮',
// People & Characters
'😀',
'😃',

View File

@@ -2,15 +2,16 @@
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
import type { Player as DBPlayer } from '@/db/schema/players'
import { useRoomData } from '@/hooks/useRoomData'
import {
useCreatePlayer,
useDeletePlayer,
useUpdatePlayer,
useUserPlayers,
} from '@/hooks/useUserPlayers'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import { getNextPlayerColor } from '../types/player'
import { generateUniquePlayerName, generateUniquePlayerNames } from '../utils/playerNames'
// Client-side Player type (compatible with old type)
export interface Player {
@@ -44,11 +45,12 @@ export interface GameModeContextType {
const GameModeContext = createContext<GameModeContextType | null>(null)
// Default players to create if none exist
const DEFAULT_PLAYERS = [
{ name: 'Player 1', emoji: '😀', color: '#3b82f6' },
{ name: 'Player 2', emoji: '😎', color: '#8b5cf6' },
{ name: 'Player 3', emoji: '🤠', color: '#10b981' },
{ name: 'Player 4', emoji: '🚀', color: '#f59e0b' },
// Names are generated randomly on first initialization
const DEFAULT_PLAYER_CONFIGS = [
{ emoji: '😀', color: '#3b82f6' },
{ emoji: '😎', color: '#8b5cf6' },
{ emoji: '🤠', color: '#10b981' },
{ emoji: '🚀', color: '#f59e0b' },
]
// Convert DB player to client Player type
@@ -139,14 +141,19 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (!isLoading && !isInitialized) {
if (dbPlayers.length === 0) {
// Create default players
DEFAULT_PLAYERS.forEach((data, index) => {
// Generate unique names for default players
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
// Create default players with generated names
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
createPlayer({
...data,
name: generatedNames[index],
emoji: config.emoji,
color: config.color,
isActive: index === 0, // First player active by default
})
})
console.log('✅ Created default players via API')
console.log('✅ Created default players via API with auto-generated names:', generatedNames)
} else {
console.log('✅ Loaded players from API', {
playerCount: dbPlayers.length,
@@ -159,9 +166,10 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
const addPlayer = (playerData?: Partial<Player>) => {
const playerList = Array.from(players.values())
const existingNames = playerList.map((p) => p.name)
const newPlayer = {
name: playerData?.name ?? `Player ${players.size + 1}`,
name: playerData?.name ?? generateUniquePlayerName(existingNames),
emoji: playerData?.emoji ?? '🎮',
color: playerData?.color ?? getNextPlayerColor(playerList),
isActive: playerData?.isActive ?? false,
@@ -246,10 +254,15 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
deletePlayer(player.id)
})
// Create default players
DEFAULT_PLAYERS.forEach((data, index) => {
// Generate unique names for default players
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
// Create default players with generated names
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
createPlayer({
...data,
name: generatedNames[index],
emoji: config.emoji,
color: config.color,
isActive: index === 0,
})
})

View File

@@ -30,12 +30,13 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
.notNull()
.default('open'),
password: text('password', { length: 255 }), // Hashed password for password-protected rooms
displayPassword: text('display_password', { length: 100 }), // Plain text password for display to room owner
// Game configuration
// Game configuration (nullable to support game selection in room)
gameName: text('game_name', {
enum: ['matching', 'memory-quiz', 'complement-race'],
}).notNull(),
gameConfig: text('game_config', { mode: 'json' }).notNull(), // Game-specific settings
}),
gameConfig: text('game_config', { mode: 'json' }), // Game-specific settings (nullable when no game selected)
// Current state
status: text('status', {

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

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
import { io, type Socket } from 'socket.io-client'
import { useViewerId } from './useViewerId'
@@ -22,7 +22,8 @@ export interface RoomData {
id: string
name: string
code: string
gameName: 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
@@ -30,7 +31,7 @@ export interface RoomData {
export interface CreateRoomParams {
name: string | null
gameName: string
gameName?: string | null // Optional - rooms can be created without a game
creatorName?: string
gameConfig?: Record<string, unknown>
accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
@@ -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 || {},
@@ -86,9 +88,9 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: params.name,
gameName: params.gameName,
gameName: params.gameName || null,
creatorName: params.creatorName || 'Player',
gameConfig: params.gameConfig || { difficulty: 6 },
gameConfig: params.gameConfig || null,
accessMode: params.accessMode,
password: params.password,
}),
@@ -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 || {},
@@ -190,7 +195,7 @@ async function getRoomByCodeApi(code: string): Promise<RoomData> {
}
export interface ModerationEvent {
type: 'kicked' | 'banned' | 'report' | 'invitation'
type: 'kicked' | 'banned' | 'report' | 'invitation' | 'join-request'
data: {
roomId?: string
kickedBy?: string
@@ -206,6 +211,10 @@ export interface ModerationEvent {
invitedByName?: string
invitationType?: 'manual' | 'auto-unban' | 'auto-create'
message?: string
// Join request fields
requestId?: string
requesterId?: string
requesterName?: string
}
}
@@ -343,13 +352,13 @@ export function useRoomData() {
}
// Moderation event handlers
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string }) => {
console.log('[useRoomData] User was kicked from room:', data)
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string; reason?: string }) => {
setModerationEvent({
type: 'kicked',
data: {
roomId: data.roomId,
kickedBy: data.kickedBy,
reason: data.reason,
},
})
// Clear room data since user was kicked
@@ -357,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: {
@@ -381,7 +389,6 @@ export function useRoomData() {
createdAt: Date
}
}) => {
console.log('[useRoomData] New report submitted:', data)
setModerationEvent({
type: 'report',
data: {
@@ -406,7 +413,6 @@ export function useRoomData() {
createdAt: Date
}
}) => {
console.log('[useRoomData] Room invitation received:', data)
setModerationEvent({
type: 'invitation',
data: {
@@ -420,6 +426,45 @@ export function useRoomData() {
})
}
const handleJoinRequestSubmitted = (data: {
roomId: string
request: {
id: string
userId: string
userName: string
createdAt: Date
}
}) => {
setModerationEvent({
type: 'join-request',
data: {
roomId: data.roomId,
requestId: data.request.id,
requesterId: data.request.userId,
requesterName: data.request.userName,
},
})
}
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)
@@ -428,6 +473,8 @@ export function useRoomData() {
socket.on('banned-from-room', handleBannedFromRoom)
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)
@@ -438,13 +485,14 @@ export function useRoomData() {
socket.off('banned-from-room', handleBannedFromRoom)
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])
@@ -530,3 +578,162 @@ export function useGetRoomByCode() {
mutationFn: getRoomByCodeApi,
})
}
/**
* Set game for a room
*/
async function setRoomGameApi(params: {
roomId: string
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(body),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to set room game')
}
}
/**
* Hook: Set game for a room
*/
export function useSetRoomGame() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: setRoomGameApi,
onSuccess: (_, variables) => {
// Update the cache with the new game
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
if (!prev) return null
return {
...prev,
gameName: variables.gameName,
}
})
// Refetch to get the full updated room data
queryClient.invalidateQueries({ queryKey: roomKeys.current() })
},
})
}
/**
* 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`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gameName: null,
// DO NOT send gameConfig: null - we want to preserve settings!
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to clear room game')
}
}
/**
* Hook: Clear/reset game for a room (returns to game selection screen)
*/
export function useClearRoomGame() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: clearRoomGameApi,
onSuccess: () => {
// Update the cache to clear the game
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
if (!prev) return null
return {
...prev,
gameName: null,
}
})
// Refetch to get the full updated room data
queryClient.invalidateQueries({ queryKey: roomKeys.current() })
},
})
}
/**
* 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

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

View File

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

View File

@@ -62,6 +62,7 @@ describe('Room Manager', () => {
ttlMinutes: 60,
accessMode: 'open',
password: null,
displayPassword: null,
gameName: 'matching',
gameConfig: { difficulty: 6 },
status: 'lobby',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import {
generatePlayerName,
generateUniquePlayerName,
generateUniquePlayerNames,
} from '../playerNames'
describe('playerNames', () => {
describe('generatePlayerName', () => {
it('should generate a player name with adjective and noun', () => {
const name = generatePlayerName()
expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/) // e.g., "Swift Ninja"
expect(name.split(' ')).toHaveLength(2)
})
it('should generate different names on multiple calls', () => {
const names = new Set()
// Generate 50 names and expect at least some variety
for (let i = 0; i < 50; i++) {
names.add(generatePlayerName())
}
// With 50 adjectives and 50 nouns, we should get many unique combinations
expect(names.size).toBeGreaterThan(30)
})
})
describe('generateUniquePlayerName', () => {
it('should generate a unique name not in existing names', () => {
const existingNames = ['Swift Ninja', 'Cosmic Wizard', 'Radiant Dragon']
const newName = generateUniquePlayerName(existingNames)
expect(existingNames).not.toContain(newName)
})
it('should be case-insensitive when checking uniqueness', () => {
const existingNames = ['swift ninja', 'COSMIC WIZARD']
const newName = generateUniquePlayerName(existingNames)
expect(existingNames.map((n) => n.toLowerCase())).not.toContain(newName.toLowerCase())
})
it('should handle empty existing names array', () => {
const name = generateUniquePlayerName([])
expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
})
it('should append number if all combinations are exhausted', () => {
// Create a mock with limited attempts
const existingNames = ['Swift Ninja']
const name = generateUniquePlayerName(existingNames, 1)
// Should either be unique or have a number appended
expect(name).toBeTruthy()
expect(name).not.toBe('Swift Ninja')
})
})
describe('generateUniquePlayerNames', () => {
it('should generate the requested number of unique names', () => {
const names = generateUniquePlayerNames(4)
expect(names).toHaveLength(4)
// All names should be unique
const uniqueNames = new Set(names)
expect(uniqueNames.size).toBe(4)
})
it('should generate unique names across all entries', () => {
const names = generateUniquePlayerNames(10)
expect(names).toHaveLength(10)
// Check uniqueness (case-insensitive)
const uniqueNames = new Set(names.map((n) => n.toLowerCase()))
expect(uniqueNames.size).toBe(10)
})
it('should handle generating zero names', () => {
const names = generateUniquePlayerNames(0)
expect(names).toHaveLength(0)
expect(names).toEqual([])
})
it('should generate names with expected format', () => {
const names = generateUniquePlayerNames(5)
for (const name of names) {
expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+( \d+)?$/)
expect(name.split(' ').length).toBeGreaterThanOrEqual(2)
}
})
})
})

View File

@@ -0,0 +1,162 @@
/**
* Fun automatic player name generation system
* Generates creative names by combining adjectives with nouns/roles
*/
const ADJECTIVES = [
// Abacus-themed adjectives
'Ancient',
'Wooden',
'Sliding',
'Decimal',
'Binary',
'Counting',
'Soroban',
'Chinese',
'Japanese',
'Nimble',
'Clicking',
'Beaded',
'Columnar',
'Vertical',
'Horizontal',
'Upper',
'Lower',
'Heaven',
'Earth',
'Golden',
'Jade',
'Bamboo',
'Polished',
'Skilled',
'Master',
// Arithmetic/calculation adjectives
'Adding',
'Subtracting',
'Multiplying',
'Dividing',
'Calculating',
'Computing',
'Estimating',
'Rounding',
'Summing',
'Tallying',
'Decimal',
'Fractional',
'Exponential',
'Algebraic',
'Geometric',
'Prime',
'Composite',
'Rational',
'Digital',
'Numeric',
'Precise',
'Accurate',
'Lightning',
'Rapid',
'Mental',
]
const NOUNS = [
// Abacus-themed nouns
'Counter',
'Abacist',
'Calculator',
'Bead',
'Rod',
'Frame',
'Slider',
'Merchant',
'Trader',
'Accountant',
'Bookkeeper',
'Clerk',
'Scribe',
'Master',
'Apprentice',
'Scholar',
'Student',
'Teacher',
'Sensei',
'Guru',
'Expert',
'Virtuoso',
'Prodigy',
'Wizard',
'Sage',
// Arithmetic/calculation nouns
'Adder',
'Multiplier',
'Divider',
'Solver',
'Mathematician',
'Arithmetician',
'Analyst',
'Computer',
'Estimator',
'Logician',
'Statistician',
'Numerologist',
'Quantifier',
'Tallier',
'Sumner',
'Keeper',
'Reckoner',
'Cipher',
'Digit',
'Figure',
'Number',
'Brain',
'Thinker',
'Genius',
'Whiz',
]
/**
* Generate a random player name by combining an adjective and noun
*/
export function generatePlayerName(): string {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
return `${adjective} ${noun}`
}
/**
* Generate a unique player name that doesn't conflict with existing players
* @param existingNames - Array of names already in use
* @param maxAttempts - Maximum attempts to find a unique name (default: 50)
* @returns A unique player name
*/
export function generateUniquePlayerName(existingNames: string[], maxAttempts = 50): string {
const existingNamesSet = new Set(existingNames.map((name) => name.toLowerCase()))
for (let i = 0; i < maxAttempts; i++) {
const name = generatePlayerName()
if (!existingNamesSet.has(name.toLowerCase())) {
return name
}
}
// Fallback: if we can't find a unique name, append a number
const baseName = generatePlayerName()
let counter = 1
while (existingNamesSet.has(`${baseName} ${counter}`.toLowerCase())) {
counter++
}
return `${baseName} ${counter}`
}
/**
* Generate a batch of unique player names
* @param count - Number of names to generate
* @returns Array of unique player names
*/
export function generateUniquePlayerNames(count: number): string[] {
const names: string[] = []
for (let i = 0; i < count; i++) {
const name = generateUniquePlayerName(names)
names.push(name)
}
return names
}

View File

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