Compare commits

...

49 Commits

Author SHA1 Message Date
semantic-release-bot
860fd607be chore(release): 3.18.0 [skip ci]
## [3.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.14...v3.18.0) (2025-10-15)

### Features

* add drizzle migration for room_game_configs table ([3bae00b](3bae00b9a9))

### Documentation

* document manual migration of room_game_configs table ([ff79140](ff791409cf))
2025-10-15 18:41:45 +00:00
Thomas Hallock
3bae00b9a9 feat: add drizzle migration for room_game_configs table
Creates migration 0011 to:
- Create room_game_configs table with proper schema
- Add unique index on (room_id, game_name)
- Migrate existing game_config data from arcade_rooms table

Migration is idempotent and safe to run on any database state:
- Uses IF NOT EXISTS for table and index creation
- Uses INSERT OR IGNORE to avoid duplicate data
- Will work on both fresh databases and existing production

This ensures production will automatically get the new table structure
when the migration runs on deployment.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:40:40 -05:00
Thomas Hallock
ff791409cf docs: document manual migration of room_game_configs table
Manual migration applied on 2025-10-15:
- Created room_game_configs table via sqlite3 CLI
- Migrated 6000 existing configs from arcade_rooms.game_config
- 5991 matching configs + 9 memory-quiz configs
- Table created directly instead of through drizzle migration system

The manually created drizzle migration SQL file has been removed since
the migration was applied directly to the database. See
.claude/MANUAL_MIGRATION_0011.md for complete details on the migration
process, verification steps, and rollback plan.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:31:55 -05:00
semantic-release-bot
c1be0277c1 chore(release): 3.17.14 [skip ci]
## [3.17.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.13...v3.17.14) (2025-10-15)

### Bug Fixes

* **arcade:** resolve TypeScript errors in game config helpers ([04c9944](04c9944f2e))

### Documentation

* **arcade:** update GAME_SETTINGS_PERSISTENCE.md for new schema ([260bdc2](260bdc2e9d))
2025-10-15 18:21:03 +00:00
Thomas Hallock
04c9944f2e fix(arcade): resolve TypeScript errors in game config helpers
Fixed three TypeScript compilation errors:

1. game-config-helpers.ts:82 - Cast existing.config to object for spread
2. game-config-helpers.ts:116 - Fixed dynamic field assignment in updateGameConfigField
3. MatchingGameValidator.ts:540 - Use proper Difficulty type in MatchingGameConfig

Changes:
- Import Difficulty and GameType from matching types
- Update MatchingGameConfig to use proper union types
- Cast existing.config as object before spreading
- Rewrite updateGameConfigField to avoid type assertion issue

All TypeScript errors resolved. Compilation passes cleanly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:19:58 -05:00
Thomas Hallock
260bdc2e9d docs(arcade): update GAME_SETTINGS_PERSISTENCE.md for new schema
Updated documentation to reflect the refactored implementation:

- Documented new room_game_configs table structure
- Explained shared type system and benefits
- Updated all code examples to use new helpers
- Revised debugging checklist for new architecture
- Added migration notes and rollback plan
- Clarified the four critical systems (was three, now includes helpers)

The documentation now accurately describes the normalized database
schema approach instead of the old monolithic JSON column.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:17:34 -05:00
semantic-release-bot
8dbdc837cc chore(release): 3.17.13 [skip ci]
## [3.17.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.12...v3.17.13) (2025-10-15)

### Code Refactoring

* **arcade:** migrate game settings to normalized database schema ([1bd7354](1bd73544df))
2025-10-15 18:17:00 +00:00
Thomas Hallock
1bd73544df refactor(arcade): migrate game settings to normalized database schema
### Schema Changes
- Create `room_game_configs` table with one row per game per room
- Migrate existing gameConfig data from arcade_rooms.game_config JSON column
- Add unique index on (roomId, gameName) for efficient queries

### Benefits
-  Type-safe config access with shared types
-  Smaller rows (only configs for used games)
-  Easier updates (single row vs entire JSON blob)
-  Better concurrency (no lock contention between games)
-  Foundation for per-game audit trail

### Core Changes
1. **Shared Config Types** (`game-configs.ts`)
   - `MatchingGameConfig`, `MemoryQuizGameConfig` interfaces
   - Default configs for each game
   - Single source of truth for all settings

2. **Helper Functions** (`game-config-helpers.ts`)
   - `getGameConfig<T>()` - type-safe config retrieval with defaults
   - `setGameConfig()` - upsert game config
   - `getAllGameConfigs()` - aggregate all game configs for a room
   - `validateGameConfig()` - runtime validation

3. **API Routes**
   - `/api/arcade/rooms/current`: Aggregates configs from new table
   - `/api/arcade/rooms/[roomId]/settings`: Writes to new table

4. **Socket Server** (`socket-server.ts`)
   - Uses `getGameConfig()` helper for session creation
   - Eliminates manual config extraction and defaults

5. **Validators**
   - `MemoryQuizGameValidator.getInitialState(config: MemoryQuizGameConfig)`
   - `MatchingGameValidator.getInitialState(config: MatchingGameConfig)`
   - Type signatures enforce consistency

### Migration Path
- Existing data migrated automatically (SQL in migration file)
- Old `gameConfig` column preserved temporarily
- Client-side providers unchanged (read from aggregated response)

Next steps:
- Test settings persistence thoroughly
- Drop old `gameConfig` column after validation
- Update documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:16:01 -05:00
semantic-release-bot
506bfeccf2 chore(release): 3.17.12 [skip ci]
## [3.17.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.11...v3.17.12) (2025-10-15)

### Code Refactoring

* **arcade:** remove non-productive debug logging from memory-quiz ([38e554e](38e554e6ea))

### Documentation

* **arcade:** document game settings persistence architecture ([8f8f112](8f8f112de2))
2025-10-15 18:05:53 +00:00
Thomas Hallock
38e554e6ea refactor(arcade): remove non-productive debug logging from memory-quiz
Removed verbose console.log statements added during settings persistence debugging:
- socket-server.ts: Removed JSON.stringify logging of gameConfig flow
- RoomMemoryQuizProvider.tsx: Removed logging from mergedInitialState useMemo and setConfig
- MemoryQuizGameValidator.ts: Removed logging from validateAcceptNumber

The actual fix (playMode parameter addition) is preserved. Debug logging was only needed to identify the root cause and is no longer necessary.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:04:49 -05:00
Thomas Hallock
8f8f112de2 docs(arcade): document game settings persistence architecture
Added comprehensive documentation for game settings persistence system
after fixing multiple settings bugs (gameType, playMode not persisting).

New documentation:
- .claude/GAME_SETTINGS_PERSISTENCE.md: Complete architecture guide
  - How settings are structured (nested by game name)
  - Three critical systems that must stay in sync
  - Common bugs with detailed solutions
  - Debugging checklist
  - Step-by-step guide for adding new settings

- .claude/GAME_SETTINGS_REFACTORING.md: Recommended improvements
  - Shared config types to prevent type mismatches
  - Helper functions to reduce duplication (getGameConfig, updateGameConfig)
  - Validator config type enforcement
  - Exhaustiveness checking
  - Runtime validation
  - Migration strategy with priority order

Updated .claude/CLAUDE.md to reference these docs with quick reference guide.

This documentation will prevent similar bugs in the future by making the
architecture explicit and providing clear patterns to follow.

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

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

### Bug Fixes

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

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

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

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

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

This will help identify any remaining persistence issues.

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

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

### Bug Fixes

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

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

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

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

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

### Bug Fixes

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

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

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

Now properly accesses the nested config for each game type.

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

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

### Bug Fixes

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

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

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

Fixes settings persistence bug in room mode.

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

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

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

### Code Refactoring

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

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

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

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

### Bug Fixes

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

This will help diagnose if settings are being persisted correctly.

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

Now settings persist when switching between games!

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

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

### Bug Fixes

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

### Code Refactoring

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

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

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

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

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:50:13 -05:00
semantic-release-bot
5f04a3b622 chore(release): 3.17.0 [skip ci]
## [3.17.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.16.0...v3.17.0) (2025-10-15)

### Features

* **memory-quiz:** persist game settings per-game across sessions ([05a8e0a](05a8e0a842))
2025-10-15 14:46:51 +00:00
Thomas Hallock
05a8e0a842 feat(memory-quiz): persist game settings per-game across sessions
Implement per-game settings persistence so that when users switch between
games and come back, their settings are restored. Settings are saved to
the room's gameConfig field in the database.

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

Settings saved: selectedCount, displayTime, selectedDifficulty, playMode

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:45:56 -05:00
semantic-release-bot
9dac9b7a36 chore(release): 3.16.0 [skip ci]
## [3.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.2...v3.16.0) (2025-10-15)

### Features

* **arcade:** broadcast game selection changes to all room members ([b99e754](b99e754395))
2025-10-15 14:44:39 +00:00
Thomas Hallock
b99e754395 feat(arcade): broadcast game selection changes to all room members
Fix issue where game selection by the host was not synchronized to other
room members. When the host selects a game, all players now see the change
in real-time via socket.io.

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:42:39 -05:00
semantic-release-bot
3eaa84d157 chore(release): 3.15.2 [skip ci]
## [3.15.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.1...v3.15.2) (2025-10-15)

### Bug Fixes

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

### Features

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

### Bug Fixes

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:16:54 -05:00
36 changed files with 3331 additions and 231 deletions

View File

@@ -1,3 +1,170 @@
## [3.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.14...v3.18.0) (2025-10-15)
### Features
* add drizzle migration for room_game_configs table ([3bae00b](https://github.com/antialias/soroban-abacus-flashcards/commit/3bae00b9a9dc925039a02fe07d036a2fc5e0fb79))
### Documentation
* document manual migration of room_game_configs table ([ff79140](https://github.com/antialias/soroban-abacus-flashcards/commit/ff791409cf4bae1a5df43eb974eacbc7612d8eec))
## [3.17.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.13...v3.17.14) (2025-10-15)
### Bug Fixes
* **arcade:** resolve TypeScript errors in game config helpers ([04c9944](https://github.com/antialias/soroban-abacus-flashcards/commit/04c9944f2ed1025f5a4ece61761889edd08cc60d))
### Documentation
* **arcade:** update GAME_SETTINGS_PERSISTENCE.md for new schema ([260bdc2](https://github.com/antialias/soroban-abacus-flashcards/commit/260bdc2e9d458cb42a96d3ed36a18134260b4520))
## [3.17.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.12...v3.17.13) (2025-10-15)
### Code Refactoring
* **arcade:** migrate game settings to normalized database schema ([1bd7354](https://github.com/antialias/soroban-abacus-flashcards/commit/1bd73544df6d62416961eea0b358955aaf82b79d))
## [3.17.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.11...v3.17.12) (2025-10-15)
### Code Refactoring
* **arcade:** remove non-productive debug logging from memory-quiz ([38e554e](https://github.com/antialias/soroban-abacus-flashcards/commit/38e554e6ea0386e48798338dd938e50ba73d5576))
### Documentation
* **arcade:** document game settings persistence architecture ([8f8f112](https://github.com/antialias/soroban-abacus-flashcards/commit/8f8f112de222e40901d4b3168fa751d233337e4b))
## [3.17.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.10...v3.17.11) (2025-10-15)
### Bug Fixes
* **memory-quiz:** fix playMode persistence by updating validator ([de0efd5](https://github.com/antialias/soroban-abacus-flashcards/commit/de0efd59321ec779cddb900724035884290419b7))
## [3.17.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.9...v3.17.10) (2025-10-15)
### Bug Fixes
* **memory-quiz:** persist playMode setting across game switches ([487ca7f](https://github.com/antialias/soroban-abacus-flashcards/commit/487ca7fba62e370c85bc3779ca8a96eb2c2cc3e3))
## [3.17.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.8...v3.17.9) (2025-10-15)
### Bug Fixes
* **arcade:** read nested gameConfig correctly when creating sessions ([94ef392](https://github.com/antialias/soroban-abacus-flashcards/commit/94ef39234d362b82e032cb69d3561b9fcb436eaf))
## [3.17.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.7...v3.17.8) (2025-10-15)
### Bug Fixes
* **arcade:** preserve game settings when returning to game selection ([0ee7739](https://github.com/antialias/soroban-abacus-flashcards/commit/0ee7739091d60580d2f98cfe288b8586b03348f3))
## [3.17.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.6...v3.17.7) (2025-10-15)
### Bug Fixes
* **arcade:** prevent gameConfig from being overwritten when switching games ([a89d3a9](https://github.com/antialias/soroban-abacus-flashcards/commit/a89d3a970137471e2652de992c45370dbb97416d))
## [3.17.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.5...v3.17.6) (2025-10-15)
### Code Refactoring
* **logging:** use JSON.stringify for all object logging ([c33698c](https://github.com/antialias/soroban-abacus-flashcards/commit/c33698ce52ebdc18ce3a0d856f9241c7389ed651))
## [3.17.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.4...v3.17.5) (2025-10-15)
### Bug Fixes
* **arcade:** implement settings persistence for matching game ([08fe432](https://github.com/antialias/soroban-abacus-flashcards/commit/08fe4326a6a7c484b9058a241f4ff79b3fb5125f))
## [3.17.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.3...v3.17.4) (2025-10-15)
### Bug Fixes
* **matching:** add settings persistence to matching game ([00dcb87](https://github.com/antialias/soroban-abacus-flashcards/commit/00dcb872b7e70bdb7de301b56fe42195e6ee923f))
## [3.17.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.2...v3.17.3) (2025-10-15)
### Bug Fixes
* **arcade:** preserve gameConfig when switching games ([2273c71](https://github.com/antialias/soroban-abacus-flashcards/commit/2273c71a872a5122d0b2023835fe30640106048e))
### Code Refactoring
* remove verbose console logging for cleaner debugging ([9cb5fdd](https://github.com/antialias/soroban-abacus-flashcards/commit/9cb5fdd2fa43560adc32dd052f47a7b06b2c5b69))
## [3.17.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.1...v3.17.2) (2025-10-15)
### Bug Fixes
* **room-data:** update query cache when gameConfig changes ([7cea297](https://github.com/antialias/soroban-abacus-flashcards/commit/7cea297095b78d74f5b77ca83489ec1be684a486))
## [3.17.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.0...v3.17.1) (2025-10-15)
### Bug Fixes
* **arcade-rooms:** navigate to invite link after room creation ([1922b21](https://github.com/antialias/soroban-abacus-flashcards/commit/1922b2122bb1bc4aeada7526d8c46aa89024bb00))
* **memory-quiz:** scope game settings by game name for proper persistence ([3dfe54f](https://github.com/antialias/soroban-abacus-flashcards/commit/3dfe54f1cb89bd636e763e1c5acb03776f97c011))
## [3.17.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.16.0...v3.17.0) (2025-10-15)
### Features
* **memory-quiz:** persist game settings per-game across sessions ([05a8e0a](https://github.com/antialias/soroban-abacus-flashcards/commit/05a8e0a84272c6c45a4014413ee00726eb88b76a))
## [3.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.2...v3.16.0) (2025-10-15)
### Features
* **arcade:** broadcast game selection changes to all room members ([b99e754](https://github.com/antialias/soroban-abacus-flashcards/commit/b99e7543952bb0d47f42e79dc4226b3c1280a0ee))
## [3.15.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.1...v3.15.2) (2025-10-15)
### Bug Fixes
* **memory-quiz:** prevent duplicate card processing from optimistic updates ([51676fc](https://github.com/antialias/soroban-abacus-flashcards/commit/51676fc15f5bc15cdb43393d3e66f7c5a0667868))
## [3.15.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.0...v3.15.1) (2025-10-15)
### Bug Fixes
* **memory-quiz:** synchronize card display across all players in multiplayer ([472f201](https://github.com/antialias/soroban-abacus-flashcards/commit/472f201088d82f92030273fadaf8a8e488820d6c))
## [3.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.4...v3.15.0) (2025-10-15)
### Features
* **memory-quiz:** add multiplayer support with redesigned scoreboards ([1cf4469](https://github.com/antialias/soroban-abacus-flashcards/commit/1cf44696c26473ce4ab2fc2039ff42f08c20edb6))
* **memory-quiz:** show player emojis on cards to indicate who found them ([05bd11a](https://github.com/antialias/soroban-abacus-flashcards/commit/05bd11a133706c9ed8c09c744da7ca8955fa979a))
### Bug Fixes
* **arcade:** add defensive checks and update test fixtures ([a93d981](https://github.com/antialias/soroban-abacus-flashcards/commit/a93d981d1ab3abed019b28cebe87525191313cc7))
## [3.14.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.3...v3.14.4) (2025-10-15)

View File

@@ -108,3 +108,31 @@ npm run check # Biome check (format + lint + organize imports)
- Lint checks
**Status:** Known issue, does not block development or deployment.
## Game Settings Persistence
When working on arcade room game settings, refer to:
- **`.claude/GAME_SETTINGS_PERSISTENCE.md`** - Complete architecture documentation
- How settings are stored (nested by game name)
- Three critical systems that must stay in sync
- Common bugs and their solutions
- Debugging checklist
- Step-by-step guide for adding new settings
- **`.claude/GAME_SETTINGS_REFACTORING.md`** - Recommended improvements
- Shared config types to prevent inconsistencies
- Helper functions to reduce duplication
- Type-safe validation
- Migration strategy
**Quick Reference:**
Settings are stored as: `gameConfig[gameName][setting]`
Three places must handle settings correctly:
1. **Provider** (`Room{Game}Provider.tsx`) - Merges saved config with defaults
2. **Socket Server** (`socket-server.ts`) - Creates session from saved config
3. **Validator** (`{Game}Validator.ts`) - `getInitialState()` must accept ALL settings
If a setting doesn't persist, check all three locations.

View File

@@ -0,0 +1,421 @@
# Game Settings Persistence Architecture
## Overview
Game settings in room mode persist across game switches using a normalized database schema. Settings for each game are stored in a dedicated `room_game_configs` table with one row per game per room.
## Database Schema
Settings are stored in the `room_game_configs` table:
```sql
CREATE TABLE room_game_configs (
id TEXT PRIMARY KEY,
room_id TEXT NOT NULL REFERENCES arcade_rooms(id) ON DELETE CASCADE,
game_name TEXT NOT NULL CHECK(game_name IN ('matching', 'memory-quiz', 'complement-race')),
config TEXT NOT NULL, -- JSON
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(room_id, game_name)
);
```
**Benefits:**
- ✅ Type-safe config access with shared types
- ✅ Smaller rows (only configs for games that have been used)
- ✅ Easier updates (single row vs entire JSON blob)
- ✅ Better concurrency (no lock contention between games)
- ✅ Foundation for per-game audit trail
- ✅ Can query/index individual game settings
**Example Row:**
```json
{
"id": "clxyz123",
"room_id": "room_abc",
"game_name": "memory-quiz",
"config": {
"selectedCount": 8,
"displayTime": 3.0,
"selectedDifficulty": "medium",
"playMode": "competitive"
},
"created_at": 1234567890,
"updated_at": 1234567890
}
```
## Shared Type System
All game configs are defined in `src/lib/arcade/game-configs.ts`:
```typescript
// Shared config types (single source of truth)
export interface MatchingGameConfig {
gameType: 'abacus-numeral' | 'complement-pairs'
difficulty: number
turnTimer: number
}
export interface MemoryQuizGameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
}
// Default configs
export const DEFAULT_MATCHING_CONFIG: MatchingGameConfig = {
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
}
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: 'easy',
playMode: 'cooperative',
}
```
**Why This Matters:**
- TypeScript enforces that validators, helpers, and API routes all use the same types
- Adding a new setting requires changes in only ONE place (the type definition)
- Impossible to forget a setting or use wrong type
## Critical Components
Settings persistence requires coordination between FOUR systems:
### 1. Helper Functions
**Location:** `src/lib/arcade/game-config-helpers.ts`
**Responsibilities:**
- Read/write game configs from `room_game_configs` table
- Provide type-safe access with automatic defaults
- Validate configs at runtime
**Key Functions:**
```typescript
// Get config with defaults (type-safe)
const config = await getGameConfig(roomId, 'memory-quiz')
// Returns: MemoryQuizGameConfig
// Set/update config (upsert)
await setGameConfig(roomId, 'memory-quiz', {
playMode: 'competitive',
selectedCount: 8,
})
// Get all game configs for a room
const allConfigs = await getAllGameConfigs(roomId)
// Returns: { matching?: MatchingGameConfig, 'memory-quiz'?: MemoryQuizGameConfig }
```
### 2. API Routes
**Location:**
- `src/app/api/arcade/rooms/current/route.ts` (read)
- `src/app/api/arcade/rooms/[roomId]/settings/route.ts` (write)
**Responsibilities:**
- Aggregate game configs from database
- Return them to client in `room.gameConfig`
- Write config updates to `room_game_configs` table
**Read Example:** `GET /api/arcade/rooms/current`
```typescript
const gameConfig = await getAllGameConfigs(roomId)
return NextResponse.json({
room: {
...room,
gameConfig, // Aggregated from room_game_configs table
},
members,
memberPlayers,
})
```
**Write Example:** `PATCH /api/arcade/rooms/[roomId]/settings`
```typescript
if (body.gameConfig !== undefined) {
// body.gameConfig: { matching: {...}, memory-quiz: {...} }
for (const [gameName, config] of Object.entries(body.gameConfig)) {
await setGameConfig(roomId, gameName, config)
}
}
```
### 3. Socket Server (Session Creation)
**Location:** `src/socket-server.ts:70-90`
**Responsibilities:**
- Create initial arcade session when user joins room
- Read saved settings using `getGameConfig()` helper
- Pass settings to validator's `getInitialState()`
**Example:**
```typescript
const room = await getRoomById(roomId)
const validator = getValidator(room.gameName as GameName)
// Get config from database (type-safe, includes defaults)
const gameConfig = await getGameConfig(roomId, room.gameName as GameName)
// Pass to validator (types match automatically)
const initialState = validator.getInitialState(gameConfig)
await createArcadeSession({ userId, gameName, initialState, roomId })
```
**Key Point:** No more manual config extraction or default fallbacks!
### 4. Game Validators
**Location:** `src/lib/arcade/validation/*Validator.ts`
**Responsibilities:**
- Define `getInitialState()` method with shared config type
- Create initial game state from config
- TypeScript enforces all settings are handled
**Example:** `MemoryQuizGameValidator.ts`
```typescript
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
class MemoryQuizGameValidator {
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
return {
selectedCount: config.selectedCount,
displayTime: config.displayTime,
selectedDifficulty: config.selectedDifficulty,
playMode: config.playMode, // TypeScript ensures this field exists!
// ...other state
}
}
}
```
### 5. Client Providers (Unchanged)
**Location:** `src/app/arcade/{game}/context/Room{Game}Provider.tsx`
**Responsibilities:**
- Read settings from `roomData.gameConfig[gameName]`
- Merge with `initialState` defaults
- Works transparently with new backend structure
**Example:** `RoomMemoryQuizProvider.tsx:211-233`
```typescript
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any>
const savedConfig = gameConfig?.['memory-quiz']
if (!savedConfig) {
return initialState
}
return {
...initialState,
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
displayTime: savedConfig.displayTime ?? initialState.displayTime,
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig.playMode ?? initialState.playMode,
}
}, [roomData?.gameConfig])
```
## Common Bugs and Solutions
### Bug #1: Settings Not Persisting
**Symptom:** Settings reset to defaults after game switch
**Root Cause:** One of the following:
1. API route not writing to `room_game_configs` table
2. Helper function not being used correctly
3. Validator not using shared config type
**Solution:** Verify the data flow:
```bash
# 1. Check database write
SELECT * FROM room_game_configs WHERE room_id = '...';
# 2. Check API logs for setGameConfig() calls
# Look for: [GameConfig] Updated {game} config for room {roomId}
# 3. Check socket server logs for getGameConfig() calls
# Look for: [join-arcade-session] Got validator for: {game}
# 4. Check validator signature matches shared type
# MemoryQuizGameValidator.getInitialState(config: MemoryQuizGameConfig)
```
### Bug #2: TypeScript Errors About Missing Fields
**Symptom:** `Property '{field}' is missing in type ...`
**Root Cause:** Validator's `getInitialState()` signature doesn't match shared config type
**Solution:** Import and use the shared config type:
```typescript
// ❌ WRONG
getInitialState(config: {
selectedCount: number
displayTime: number
// Missing playMode!
}): SorobanQuizState
// ✅ CORRECT
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState
```
### Bug #3: Settings Wiped When Returning to Game Selection
**Symptom:** Settings reset when going back to game selection
**Root Cause:** Sending `gameConfig: null` in PATCH request
**Solution:** Only send `gameName: null`, don't touch gameConfig:
```typescript
// ❌ WRONG
body: JSON.stringify({ gameName: null, gameConfig: null })
// ✅ CORRECT
body: JSON.stringify({ gameName: null })
```
## Debugging Checklist
When a setting doesn't persist:
1. **Check database:**
- Query `room_game_configs` table
- Verify row exists for room + game
- Verify JSON config has correct structure
2. **Check API write path:**
- `/api/arcade/rooms/[roomId]/settings` logs
- Verify `setGameConfig()` is called
- Check for errors in console
3. **Check API read path:**
- `/api/arcade/rooms/current` logs
- Verify `getAllGameConfigs()` returns data
- Check `room.gameConfig` in response
4. **Check socket server:**
- `socket-server.ts` logs for `getGameConfig()`
- Verify config passed to validator
- Check `initialState` has correct values
5. **Check validator:**
- Signature uses shared config type
- All config fields used (not hardcoded)
- Add logging to see received config
## Adding a New Setting
To add a new setting to an existing game:
1. **Update the shared config type** (`game-configs.ts`):
```typescript
export interface MemoryQuizGameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
newSetting: string // ← Add here
}
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: 'easy',
playMode: 'cooperative',
newSetting: 'default', // ← Add default
}
```
2. **TypeScript will now enforce:**
- ✅ Validator must accept `newSetting` (compile error if missing)
- ✅ Helper functions will include it automatically
- ✅ Client providers will need to handle it
3. **Update the validator** (`*Validator.ts`):
```typescript
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
return {
// ...
newSetting: config.newSetting, // TypeScript enforces this
}
}
```
4. **Update the UI** to expose the new setting
- No changes needed to API routes or helper functions!
- They automatically handle any field in the config type
## Testing Settings Persistence
Manual test procedure:
1. Join a room and select a game
2. Change each setting to a non-default value
3. Go back to game selection (gameName becomes null)
4. Select the same game again
5. **Verify ALL settings retained their values**
**Expected behavior:** All settings should be exactly as you left them.
## Migration Notes
**Old Schema:**
- Settings stored in `arcade_rooms.game_config` JSON column
- Config stored directly for currently selected game only
- Config lost when switching games
**New Schema:**
- Settings stored in `room_game_configs` table
- One row per game per room
- Unique constraint on (room_id, game_name)
- Configs persist when switching between games
**Migration:** See `.claude/MANUAL_MIGRATION_0011.md` for complete details
**Summary:**
- Manual migration applied on 2025-10-15
- Created `room_game_configs` table via sqlite3 CLI
- Migrated 6000 existing configs (5991 matching, 9 memory-quiz)
- Table created directly instead of through drizzle migration system
**Rollback Plan:**
- Old `game_config` column still exists in `arcade_rooms` table
- Old data preserved (was only read, not deleted)
- Can revert to reading from old column if needed
- New table can be dropped: `DROP TABLE room_game_configs`
## Architecture Benefits
**Type Safety:**
- Single source of truth for config types
- TypeScript enforces consistency everywhere
- Impossible to forget a setting
**DRY (Don't Repeat Yourself):**
- No duplicated default values
- No manual config extraction
- No manual merging with defaults
**Maintainability:**
- Adding a setting touches fewer places
- Clear separation of concerns
- Easier to trace data flow
**Performance:**
- Smaller database rows
- Better query performance
- Less network payload
**Correctness:**
- Runtime validation available
- Database constraints (unique index)
- Impossible to create duplicate configs

View File

@@ -0,0 +1,479 @@
# Game Settings Persistence - Refactoring Recommendations
## Current Pain Points
1. **Type safety is weak** - Easy to forget to add a setting in one place
2. **Duplication** - Config reading logic duplicated in socket-server.ts for each game
3. **Manual synchronization** - Have to manually keep validator signature, provider, and socket server in sync
4. **Error-prone** - Easy to hardcode values or forget to read from config
## Recommended Refactorings
### 1. Create Shared Config Types (HIGHEST PRIORITY)
**Problem:** Each game's settings are defined in multiple places with no type enforcement
**Solution:** Define a single source of truth for each game's config
```typescript
// src/lib/arcade/game-configs.ts
export interface MatchingGameConfig {
gameType: 'abacus-numeral' | 'complement-pairs'
difficulty: number
turnTimer: number
}
export interface MemoryQuizGameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
}
export interface ComplementRaceGameConfig {
// ... future settings
}
export interface RoomGameConfig {
matching?: MatchingGameConfig
'memory-quiz'?: MemoryQuizGameConfig
'complement-race'?: ComplementRaceGameConfig
}
// Default configs
export const DEFAULT_MATCHING_CONFIG: MatchingGameConfig = {
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
}
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: 'easy',
playMode: 'cooperative',
}
```
**Benefits:**
- Single source of truth for each game's settings
- TypeScript enforces consistency across codebase
- Easy to see what settings each game has
### 2. Create Config Helper Functions
**Problem:** Config reading logic is duplicated and error-prone
**Solution:** Centralized helper functions with type safety
```typescript
// src/lib/arcade/game-config-helpers.ts
import type { GameName } from './validation'
import type { RoomGameConfig, MatchingGameConfig, MemoryQuizGameConfig } from './game-configs'
import { DEFAULT_MATCHING_CONFIG, DEFAULT_MEMORY_QUIZ_CONFIG } from './game-configs'
/**
* Get game-specific config from room's gameConfig with defaults
*/
export function getGameConfig<T extends GameName>(
roomGameConfig: RoomGameConfig | null | undefined,
gameName: T
): T extends 'matching'
? MatchingGameConfig
: T extends 'memory-quiz'
? MemoryQuizGameConfig
: never {
if (!roomGameConfig) {
return getDefaultGameConfig(gameName) as any
}
const savedConfig = roomGameConfig[gameName]
if (!savedConfig) {
return getDefaultGameConfig(gameName) as any
}
// Merge saved config with defaults to handle missing fields
const defaults = getDefaultGameConfig(gameName)
return { ...defaults, ...savedConfig } as any
}
function getDefaultGameConfig(gameName: GameName) {
switch (gameName) {
case 'matching':
return DEFAULT_MATCHING_CONFIG
case 'memory-quiz':
return DEFAULT_MEMORY_QUIZ_CONFIG
case 'complement-race':
// return DEFAULT_COMPLEMENT_RACE_CONFIG
throw new Error('complement-race config not implemented')
default:
throw new Error(`Unknown game: ${gameName}`)
}
}
/**
* Update a specific game's config in the room's gameConfig
*/
export function updateGameConfig<T extends GameName>(
currentRoomConfig: RoomGameConfig | null | undefined,
gameName: T,
updates: Partial<T extends 'matching' ? MatchingGameConfig : T extends 'memory-quiz' ? MemoryQuizGameConfig : never>
): RoomGameConfig {
const current = currentRoomConfig || {}
const gameConfig = current[gameName] || getDefaultGameConfig(gameName)
return {
...current,
[gameName]: {
...gameConfig,
...updates,
},
}
}
```
**Usage in socket-server.ts:**
```typescript
// BEFORE (error-prone, duplicated)
const memoryQuizConfig = (room.gameConfig as any)?.['memory-quiz'] || {}
initialState = validator.getInitialState({
selectedCount: memoryQuizConfig.selectedCount || 5,
displayTime: memoryQuizConfig.displayTime || 2.0,
selectedDifficulty: memoryQuizConfig.selectedDifficulty || 'easy',
playMode: memoryQuizConfig.playMode || 'cooperative',
})
// AFTER (type-safe, concise)
const config = getGameConfig(room.gameConfig, 'memory-quiz')
initialState = validator.getInitialState(config)
```
**Usage in RoomMemoryQuizProvider.tsx:**
```typescript
// BEFORE (verbose, error-prone)
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any>
const savedConfig = gameConfig?.['memory-quiz']
return {
...initialState,
selectedCount: savedConfig?.selectedCount ?? initialState.selectedCount,
displayTime: savedConfig?.displayTime ?? initialState.displayTime,
selectedDifficulty: savedConfig?.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig?.playMode ?? initialState.playMode,
}
}, [roomData?.gameConfig])
// AFTER (type-safe, concise)
const mergedInitialState = useMemo(() => {
const config = getGameConfig(roomData?.gameConfig, 'memory-quiz')
return {
...initialState,
...config, // Spread config directly - all settings included
}
}, [roomData?.gameConfig])
```
**Benefits:**
- No more manual property-by-property merging
- Type-safe
- Defaults handled automatically
- Reusable across codebase
### 3. Enforce Validator Config Type from Game Config
**Problem:** Easy to forget to add a new setting to validator's `getInitialState()` signature
**Solution:** Make validator use the shared config type
```typescript
// src/lib/arcade/validation/MemoryQuizGameValidator.ts
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
export class MemoryQuizGameValidator {
// BEFORE: Manual type definition
// getInitialState(config: {
// selectedCount: number
// displayTime: number
// selectedDifficulty: DifficultyLevel
// playMode?: 'cooperative' | 'competitive'
// }): SorobanQuizState
// AFTER: Use shared type
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
return {
// ...
selectedCount: config.selectedCount,
displayTime: config.displayTime,
selectedDifficulty: config.selectedDifficulty,
playMode: config.playMode, // TypeScript ensures all fields are handled
// ...
}
}
}
```
**Benefits:**
- If you add a setting to `MemoryQuizGameConfig`, TypeScript forces you to handle it
- Impossible to forget a setting
- Impossible to use wrong type
### 4. Add Exhaustiveness Checking
**Problem:** Easy to miss handling a setting field
**Solution:** Use TypeScript's exhaustiveness checking
```typescript
// src/lib/arcade/validation/MemoryQuizGameValidator.ts
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
// Exhaustiveness check - ensures all config fields are used
const _exhaustivenessCheck: Record<keyof MemoryQuizGameConfig, boolean> = {
selectedCount: true,
displayTime: true,
selectedDifficulty: true,
playMode: true,
}
return {
// ... use all config fields
selectedCount: config.selectedCount,
displayTime: config.displayTime,
selectedDifficulty: config.selectedDifficulty,
playMode: config.playMode,
}
}
```
If you add a new field to `MemoryQuizGameConfig`, TypeScript will error on `_exhaustivenessCheck` until you add it.
### 5. Validate Config on Save
**Problem:** Invalid config can be saved to database
**Solution:** Add runtime validation
```typescript
// src/lib/arcade/game-config-helpers.ts
export function validateGameConfig(
gameName: GameName,
config: any
): config is MatchingGameConfig | MemoryQuizGameConfig {
switch (gameName) {
case 'matching':
return (
typeof config.gameType === 'string' &&
['abacus-numeral', 'complement-pairs'].includes(config.gameType) &&
typeof config.difficulty === 'number' &&
config.difficulty > 0 &&
typeof config.turnTimer === 'number' &&
config.turnTimer > 0
)
case 'memory-quiz':
return (
[2, 5, 8, 12, 15].includes(config.selectedCount) &&
typeof config.displayTime === 'number' &&
config.displayTime > 0 &&
['beginner', 'easy', 'medium', 'hard', 'expert'].includes(config.selectedDifficulty) &&
['cooperative', 'competitive'].includes(config.playMode)
)
default:
return false
}
}
```
Use in settings API:
```typescript
// src/app/api/arcade/rooms/[roomId]/settings/route.ts
if (body.gameConfig !== undefined) {
if (!validateGameConfig(room.gameName, body.gameConfig[room.gameName])) {
return NextResponse.json({ error: 'Invalid game config' }, { status: 400 })
}
updateData.gameConfig = body.gameConfig
}
```
## Schema Refactoring: Separate Table for Game Configs
### Current Problem
All game configs are stored in a single JSON column in `arcade_rooms.gameConfig`:
```json
{
"matching": { "gameType": "...", "difficulty": 15 },
"memory-quiz": { "selectedCount": 8, "playMode": "competitive" }
}
```
**Issues:**
- No schema validation
- Inefficient updates (read/parse/modify/serialize entire blob)
- Grows without bounds as more games added
- Can't query or index individual game settings
- No audit trail
- Potential concurrent update race conditions
### Recommended: Separate Table
Create `room_game_configs` table with one row per game per room:
```typescript
// src/db/schema/room-game-configs.ts
export const roomGameConfigs = sqliteTable('room_game_configs', {
id: text('id').primaryKey().$defaultFn(() => createId()),
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
gameName: text('game_name', {
enum: ['matching', 'memory-quiz', 'complement-race'],
}).notNull(),
config: text('config', { mode: 'json' }).notNull(), // Game-specific JSON
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
}, (table) => ({
uniqueRoomGame: uniqueIndex('room_game_idx').on(table.roomId, table.gameName),
}))
```
**Benefits:**
- ✅ Smaller rows (only configs for games that have been used)
- ✅ Easier updates (single row, not entire JSON blob)
- ✅ Can track updatedAt per game
- ✅ Better concurrency (no lock contention between games)
- ✅ Foundation for future audit trail
**Migration Strategy:**
1. Create new table
2. Migrate existing data from `arcade_rooms.gameConfig`
3. Update all config read/write code
4. Deploy and test
5. Drop old `gameConfig` column from `arcade_rooms`
See migration SQL below.
## Implementation Priority
### Phase 1: Schema Migration (HIGHEST PRIORITY)
1. **Create new table** - Add `room_game_configs` schema
2. **Create migration** - SQL to migrate existing data
3. **Update helper functions** - Adapt to new table structure
4. **Update all read/write code** - Use new table
5. **Test thoroughly** - Verify all settings persist correctly
6. **Drop old column** - Remove `gameConfig` from `arcade_rooms`
### Phase 2: Type Safety (HIGH)
1. **Create shared config types** (`game-configs.ts`) - Prevents type mismatches
2. **Create helper functions** (`game-config-helpers.ts`) - Now queries new table
3. **Update validators** to use shared types - Enforces consistency
### Phase 3: Compile-Time Safety (MEDIUM)
1. **Add exhaustiveness checking** - Catches missing fields at compile time
2. **Enforce validator config types** - Use shared types
### Phase 4: Runtime Safety (LOW)
1. **Add runtime validation** - Prevents invalid data from being saved
## Detailed Migration SQL
```sql
-- drizzle/migrations/XXXX_split_game_configs.sql
-- Create new table
CREATE TABLE room_game_configs (
id TEXT PRIMARY KEY,
room_id TEXT NOT NULL REFERENCES arcade_rooms(id) ON DELETE CASCADE,
game_name TEXT NOT NULL CHECK(game_name IN ('matching', 'memory-quiz', 'complement-race')),
config TEXT NOT NULL, -- JSON
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX room_game_idx ON room_game_configs(room_id, game_name);
-- Migrate existing 'matching' configs
INSERT INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
SELECT
lower(hex(randomblob(16))),
id,
'matching',
json_extract(game_config, '$.matching'),
created_at,
last_activity
FROM arcade_rooms
WHERE json_extract(game_config, '$.matching') IS NOT NULL;
-- Migrate existing 'memory-quiz' configs
INSERT INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
SELECT
lower(hex(randomblob(16))),
id,
'memory-quiz',
json_extract(game_config, '$."memory-quiz"'),
created_at,
last_activity
FROM arcade_rooms
WHERE json_extract(game_config, '$."memory-quiz"') IS NOT NULL;
-- After testing and verifying all works:
-- ALTER TABLE arcade_rooms DROP COLUMN game_config;
```
## Migration Strategy
### Step-by-Step with Checkpoints
**Checkpoint 1: Schema & Migration**
1. Create `src/db/schema/room-game-configs.ts`
2. Export from `src/db/schema/index.ts`
3. Generate and apply migration
4. Verify data migrated correctly
**Checkpoint 2: Helper Functions**
1. Create shared config types in `src/lib/arcade/game-configs.ts`
2. Create helper functions in `src/lib/arcade/game-config-helpers.ts`
3. Add unit tests for helpers
**Checkpoint 3: Update Config Reads**
1. Update socket-server.ts to read from new table
2. Update RoomMemoryQuizProvider to read from new table
3. Update RoomMemoryPairsProvider to read from new table
4. Test: Load room and verify settings appear
**Checkpoint 4: Update Config Writes**
1. Update useRoomData.ts updateGameConfig to write to new table
2. Update settings API to write to new table
3. Test: Change settings and verify they persist
**Checkpoint 5: Update Validators**
1. Update validators to use shared config types
2. Test: All games work correctly
**Checkpoint 6: Cleanup**
1. Remove old gameConfig column references
2. Drop gameConfig column from arcade_rooms table
3. Final testing of all games
## Benefits Summary
- **Type Safety:** TypeScript enforces consistency across all systems
- **DRY:** Config reading logic not duplicated
- **Maintainability:** Adding a setting requires changes in fewer places
- **Correctness:** Impossible to forget a setting or use wrong type
- **Debugging:** Centralized config logic easier to trace
- **Testing:** Can test config helpers in isolation

View File

@@ -0,0 +1,120 @@
# Manual Migration: room_game_configs Table
**Date:** 2025-10-15
**Migration:** Create `room_game_configs` table (equivalent to drizzle migration 0011)
## Context
This migration was applied manually using sqlite3 CLI instead of through drizzle-kit's migration system, because the interactive prompt from `drizzle-kit push` cannot be automated in the deployment pipeline.
## What Was Done
### 1. Created Table
```sql
CREATE TABLE IF NOT EXISTS room_game_configs (
id TEXT PRIMARY KEY NOT NULL,
room_id TEXT NOT NULL,
game_name TEXT NOT NULL,
config TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (room_id) REFERENCES arcade_rooms(id) ON UPDATE NO ACTION ON DELETE CASCADE
);
```
### 2. Created Index
```sql
CREATE UNIQUE INDEX IF NOT EXISTS room_game_idx ON room_game_configs (room_id, game_name);
```
### 3. Migrated Existing Data
Migrated 6000 game configs from the old `arcade_rooms.game_config` column to the new normalized table:
```sql
INSERT OR IGNORE INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
SELECT
lower(hex(randomblob(16))) as id,
id as room_id,
game_name,
game_config as config,
created_at,
last_activity as updated_at
FROM arcade_rooms
WHERE game_config IS NOT NULL
AND game_name IS NOT NULL;
```
**Results:**
- 5991 matching game configs migrated
- 9 memory-quiz game configs migrated
- Total: 6000 configs
## Old vs New Schema
**Old Schema:**
- `arcade_rooms.game_config` (TEXT/JSON) - stored config for currently selected game only
- Config was lost when switching games
**New Schema:**
- `room_game_configs` table - one row per game per room
- Unique constraint on (room_id, game_name)
- Configs persist when switching between games
## Verification
```bash
# Verify table exists
sqlite3 data/sqlite.db ".tables" | grep room_game_configs
# Verify schema
sqlite3 data/sqlite.db ".schema room_game_configs"
# Count migrated data
sqlite3 data/sqlite.db "SELECT COUNT(*) FROM room_game_configs;"
# Expected: 6000
# Check data distribution
sqlite3 data/sqlite.db "SELECT game_name, COUNT(*) FROM room_game_configs GROUP BY game_name;"
# Expected: matching: 5991, memory-quiz: 9
```
## Related Files
This migration supports the refactoring documented in:
- `.claude/GAME_SETTINGS_PERSISTENCE.md` - Architecture documentation
- `src/lib/arcade/game-configs.ts` - Shared config types
- `src/lib/arcade/game-config-helpers.ts` - Database access helpers
## Note on Drizzle Migration Tracking
This migration was NOT recorded in drizzle's `__drizzle_migrations` table because it was applied manually. This is acceptable because:
1. The schema definition exists in code (`src/db/schema/room-game-configs.ts`)
2. The table was created with the exact schema drizzle would generate
3. Future schema changes will go through proper drizzle migrations
4. The `arcade_rooms.game_config` column is preserved for rollback safety
## Rollback Plan
If issues arise, the old system can be restored by:
1. Reverting code changes (game-config-helpers.ts, API routes, validators)
2. The old `game_config` column still exists in `arcade_rooms` table
3. Data is still there (we only read from it, didn't delete it)
The new `room_game_configs` table can be dropped if needed:
```sql
DROP TABLE IF EXISTS room_game_configs;
```
## Future Work
Once this migration is stable in production:
1. Consider dropping the old `arcade_rooms.game_config` column
2. Add this migration to drizzle's migration journal for tracking (optional)
3. Monitor for any issues with settings persistence

View File

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

View File

@@ -0,0 +1,33 @@
-- Create room_game_configs table for normalized game settings storage
-- This migration is safe to run multiple times (uses IF NOT EXISTS)
-- Create the table
CREATE TABLE IF NOT EXISTS `room_game_configs` (
`id` text PRIMARY KEY NOT NULL,
`room_id` text NOT NULL,
`game_name` text NOT NULL,
`config` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
-- Create unique index
CREATE UNIQUE INDEX IF NOT EXISTS `room_game_idx` ON `room_game_configs` (`room_id`,`game_name`);
--> statement-breakpoint
-- Migrate existing game configs from arcade_rooms.game_config column
-- This INSERT will only run if data hasn't been migrated yet
INSERT OR IGNORE INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
SELECT
lower(hex(randomblob(16))) as id,
id as room_id,
game_name,
game_config as config,
created_at,
last_activity as updated_at
FROM arcade_rooms
WHERE game_config IS NOT NULL
AND game_name IS NOT NULL
AND game_name IN ('matching', 'memory-quiz', 'complement-race');

View File

@@ -78,6 +78,13 @@
"when": 1760700000000,
"tag": "0010_make_game_name_nullable",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1760800000000,
"tag": "0011_add_room_game_configs",
"breakpoints": true
}
]
}

View File

@@ -7,6 +7,8 @@ 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 { getAllGameConfigs, setGameConfig } from '@/lib/arcade/game-config-helpers'
import type { GameName } from '@/lib/arcade/validation'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -27,6 +29,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)
@@ -92,11 +124,23 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
updateData.gameName = body.gameName
}
// Update game config if provided
if (body.gameConfig !== undefined) {
updateData.gameConfig = body.gameConfig
// Handle game config updates - write to new room_game_configs table
if (body.gameConfig !== undefined && body.gameConfig !== null) {
// body.gameConfig is expected to be nested by game name: { matching: {...}, memory-quiz: {...} }
// Extract each game's config and write to the new table
for (const [gameName, config] of Object.entries(body.gameConfig)) {
if (config && typeof config === 'object') {
await setGameConfig(roomId, gameName as GameName, config)
console.log(`[Settings API] Wrote ${gameName} config to room_game_configs table`)
}
}
}
console.log(
'[Settings API] Update data to be written to database:',
JSON.stringify(updateData, null, 2)
)
// If game is being changed (or cleared), delete the existing arcade session
// This ensures a fresh session will be created with the new game settings
if (body.gameName !== undefined) {
@@ -111,6 +155,44 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
.where(eq(schema.arcadeRooms.id, roomId))
.returning()
// Get aggregated game configs from new table
const gameConfig = await getAllGameConfigs(roomId)
console.log(
'[Settings API] Room state in database AFTER update:',
JSON.stringify(
{
gameName: updatedRoom.gameName,
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,
gameConfig, // Include aggregated configs from new table
}
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)
@@ -177,7 +259,15 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
}
}
return NextResponse.json({ room: updatedRoom }, { status: 200 })
return NextResponse.json(
{
room: {
...updatedRoom,
gameConfig, // Include aggregated configs from new table
},
},
{ status: 200 }
)
} catch (error: any) {
console.error('Failed to update room settings:', error)
return NextResponse.json({ error: 'Failed to update room settings' }, { status: 500 })

View File

@@ -4,6 +4,7 @@ import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import { getAllGameConfigs } from '@/lib/arcade/game-config-helpers'
/**
* GET /api/arcade/rooms/current
@@ -28,6 +29,22 @@ export async function GET() {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Get game configs from new room_game_configs table
const gameConfig = await getAllGameConfigs(roomId)
console.log(
'[Current Room API] Room data READ from database:',
JSON.stringify(
{
roomId,
gameName: room.gameName,
gameConfig,
},
null,
2
)
)
// Get members
const members = await getRoomMembers(roomId)
@@ -41,7 +58,10 @@ export async function GET() {
}
return NextResponse.json({
room,
room: {
...room,
gameConfig, // Override with configs from new table
},
members,
memberPlayers: memberPlayersObj,
})

View File

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

View File

@@ -2,7 +2,7 @@
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import {
buildPlayerMetadata as buildPlayerMetadataUtil,
@@ -90,16 +90,19 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
case 'FLIP_CARD': {
// Optimistically flip the card
const card = state.gameCards.find((c) => c.id === move.data.cardId)
// Defensive check: ensure arrays exist
const gameCards = state.gameCards || []
const flippedCards = state.flippedCards || []
const card = gameCards.find((c) => c.id === move.data.cardId)
if (!card) return state
const newFlippedCards = [...state.flippedCards, card]
const newFlippedCards = [...flippedCards, card]
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime:
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
currentMoveStartTime: flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
showMismatchFeedback: false,
}
@@ -237,6 +240,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData() // Fetch room data for room-based play
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
@@ -244,8 +248,77 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
// NO LOCAL STATE - Configuration lives in session state
// Changes are sent as moves and synchronized across all room members
// Track roomData.gameConfig changes
useEffect(() => {
console.log(
'[RoomMemoryPairsProvider] roomData.gameConfig changed:',
JSON.stringify(
{
gameConfig: roomData?.gameConfig,
roomId: roomData?.id,
gameName: roomData?.gameName,
},
null,
2
)
)
}, [roomData?.gameConfig, roomData?.id, roomData?.gameName])
// Merge saved game config from room with initialState
// Settings are scoped by game name to preserve settings when switching games
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
console.log(
'[RoomMemoryPairsProvider] Loading settings from database:',
JSON.stringify(
{
gameConfig,
roomId: roomData?.id,
},
null,
2
)
)
if (!gameConfig) {
console.log('[RoomMemoryPairsProvider] No gameConfig, using initialState')
return initialState
}
// Get settings for this specific game (matching)
const savedConfig = gameConfig.matching as Record<string, any> | null | undefined
console.log(
'[RoomMemoryPairsProvider] Saved config for matching:',
JSON.stringify(savedConfig, null, 2)
)
if (!savedConfig) {
console.log('[RoomMemoryPairsProvider] No saved config for matching, using initialState')
return initialState
}
const merged = {
...initialState,
// Restore settings from saved config
gameType: savedConfig.gameType ?? initialState.gameType,
difficulty: savedConfig.difficulty ?? initialState.difficulty,
turnTimer: savedConfig.turnTimer ?? initialState.turnTimer,
}
console.log(
'[RoomMemoryPairsProvider] Merged state:',
JSON.stringify(
{
gameType: merged.gameType,
difficulty: merged.difficulty,
turnTimer: merged.turnTimer,
},
null,
2
)
)
return merged
}, [roomData?.gameConfig])
// Arcade session integration WITH room sync
const {
@@ -256,39 +329,55 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
} = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
initialState,
initialState: mergedInitialState,
applyMove: applyMoveOptimistically,
})
// Detect state corruption/mismatch (e.g., game type mismatch between sessions)
const hasStateCorruption =
!state.gameCards || !state.flippedCards || !Array.isArray(state.gameCards)
// Handle mismatch feedback timeout
useEffect(() => {
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
// Defensive check: ensure flippedCards exists
if (state.showMismatchFeedback && state.flippedCards?.length === 2) {
// After 1.5 seconds, send CLEAR_MISMATCH
// Server will validate that cards are still in mismatch state before clearing
const timeout = setTimeout(() => {
sendMove({
type: 'CLEAR_MISMATCH',
playerId: state.currentPlayer,
userId: viewerId || '',
data: {},
})
}, 1500)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
}, [
state.showMismatchFeedback,
state.flippedCards?.length,
sendMove,
state.currentPlayer,
viewerId,
])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = useCallback(
(cardId: string): boolean => {
// Defensive check: ensure required state exists
const flippedCards = state.flippedCards || []
const gameCards = state.gameCards || []
console.log('[RoomProvider][canFlipCard] Checking card:', {
cardId,
isGameActive,
isProcessingMove: state.isProcessingMove,
currentPlayer: state.currentPlayer,
hasRoomData: !!roomData,
flippedCardsCount: state.flippedCards.length,
flippedCardsCount: flippedCards.length,
})
if (!isGameActive || state.isProcessingMove) {
@@ -296,20 +385,20 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
return false
}
const card = state.gameCards.find((c) => c.id === cardId)
const card = gameCards.find((c) => c.id === cardId)
if (!card || card.matched) {
console.log('[RoomProvider][canFlipCard] Blocked: card not found or already matched')
return false
}
// Can't flip if already flipped
if (state.flippedCards.some((c) => c.id === cardId)) {
if (flippedCards.some((c) => c.id === cardId)) {
console.log('[RoomProvider][canFlipCard] Blocked: card already flipped')
return false
}
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) {
if (flippedCards.length >= 2) {
console.log('[RoomProvider][canFlipCard] Blocked: 2 cards already flipped')
return false
}
@@ -414,13 +503,14 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'START_GAME',
playerId: firstPlayer,
userId: viewerId || '',
data: {
cards,
activePlayers,
playerMetadata,
},
})
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove, viewerId])
const flipCard = useCallback(
(cardId: string) => {
@@ -441,6 +531,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const move = {
type: 'FLIP_CARD' as const,
playerId: state.currentPlayer, // Use the current player ID from game state (database player ID)
userId: viewerId || '',
data: { cardId },
}
console.log('[RoomProvider] Sending FLIP_CARD move via sendMove:', move)
@@ -466,49 +557,152 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'START_GAME',
playerId: firstPlayer,
userId: viewerId || '',
data: {
cards,
activePlayers,
playerMetadata,
},
})
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove, viewerId])
const setGameType = useCallback(
(gameType: typeof state.gameType) => {
console.log('[RoomMemoryPairsProvider] setGameType called:', gameType)
// Use first active player as playerId, or empty string if none
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
userId: viewerId || '',
data: { field: 'gameType', value: gameType },
})
// Save setting to room's gameConfig for persistence
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
const updatedConfig = {
...currentGameConfig,
matching: {
...currentMatchingConfig,
gameType,
},
}
console.log(
'[RoomMemoryPairsProvider] Saving gameType to database:',
JSON.stringify(
{
roomId: roomData.id,
updatedConfig,
},
null,
2
)
)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryPairsProvider] Cannot save gameType - no roomData.id')
}
},
[activePlayers, sendMove]
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
const setDifficulty = useCallback(
(difficulty: typeof state.difficulty) => {
console.log('[RoomMemoryPairsProvider] setDifficulty called:', difficulty)
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
userId: viewerId || '',
data: { field: 'difficulty', value: difficulty },
})
// Save setting to room's gameConfig for persistence
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
const updatedConfig = {
...currentGameConfig,
matching: {
...currentMatchingConfig,
difficulty,
},
}
console.log(
'[RoomMemoryPairsProvider] Saving difficulty to database:',
JSON.stringify(
{
roomId: roomData.id,
updatedConfig,
},
null,
2
)
)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryPairsProvider] Cannot save difficulty - no roomData.id')
}
},
[activePlayers, sendMove]
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
const setTurnTimer = useCallback(
(turnTimer: typeof state.turnTimer) => {
console.log('[RoomMemoryPairsProvider] setTurnTimer called:', turnTimer)
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
userId: viewerId || '',
data: { field: 'turnTimer', value: turnTimer },
})
// Save setting to room's gameConfig for persistence
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
const updatedConfig = {
...currentGameConfig,
matching: {
...currentMatchingConfig,
turnTimer,
},
}
console.log(
'[RoomMemoryPairsProvider] Saving turnTimer to database:',
JSON.stringify(
{
roomId: roomData.id,
updatedConfig,
},
null,
2
)
)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryPairsProvider] Cannot save turnTimer - no roomData.id')
}
},
[activePlayers, sendMove]
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
const goToSetup = useCallback(() => {
@@ -517,9 +711,10 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'GO_TO_SETUP',
playerId,
userId: viewerId || '',
data: {},
})
}, [activePlayers, state.currentPlayer, sendMove])
}, [activePlayers, state.currentPlayer, sendMove, viewerId])
const resumeGame = useCallback(() => {
// PAUSE/RESUME: Resume paused game if config unchanged
@@ -532,9 +727,10 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'RESUME_GAME',
playerId,
userId: viewerId || '',
data: {},
})
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove, viewerId])
const hoverCard = useCallback(
(cardId: string | null) => {
@@ -546,10 +742,11 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove({
type: 'HOVER_CARD',
playerId,
userId: viewerId || '',
data: { cardId },
})
},
[state.currentPlayer, activePlayers, sendMove]
[state.currentPlayer, activePlayers, sendMove, viewerId]
)
// NO MORE effectiveState merging! Just use session state directly with gameMode added
@@ -557,6 +754,100 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
gameMode: GameMode
}
// If state is corrupted, show error message instead of crashing
if (hasStateCorruption) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
textAlign: 'center',
minHeight: '400px',
}}
>
<div
style={{
fontSize: '48px',
marginBottom: '20px',
}}
>
</div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#dc2626',
}}
>
Game State Mismatch
</h2>
<p
style={{
fontSize: '16px',
color: '#6b7280',
marginBottom: '24px',
maxWidth: '500px',
}}
>
There's a mismatch between game types in this room. This usually happens when room members
are playing different games.
</p>
<div
style={{
background: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px',
maxWidth: '500px',
}}
>
<p
style={{
fontSize: '14px',
fontWeight: '600',
marginBottom: '8px',
}}
>
To fix this:
</p>
<ol
style={{
fontSize: '14px',
textAlign: 'left',
paddingLeft: '20px',
lineHeight: '1.6',
}}
>
<li>Make sure all room members are on the same game page</li>
<li>Try refreshing the page</li>
<li>If the issue persists, leave and rejoin the room</li>
</ol>
</div>
<button
onClick={() => window.location.reload()}
style={{
padding: '10px 20px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Refresh Page
</button>
</div>
)
}
const contextValue: MemoryPairsContextValue = {
state: effectiveState,
dispatch: () => {

View File

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

View File

@@ -1,24 +1,14 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { isPrefix } from '@/lib/memory-quiz-utils'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { CardGrid } from './CardGrid'
export function InputPhase() {
const { state, dispatch, acceptNumber, rejectNumber, setInput, showResults } = useMemoryQuiz()
const _containerRef = useRef<HTMLDivElement>(null)
const [displayFeedback, setDisplayFeedback] = useState<'neutral' | 'correct' | 'incorrect'>(
'neutral'
)
// Use a ref to track the current input to avoid stale reads during rapid typing
// This prevents the "type 8 times" issue where state.currentInput hasn't updated yet
const currentInputRef = useRef(state.currentInput)
// Keep ref in sync with state
useEffect(() => {
currentInputRef.current = state.currentInput
}, [state.currentInput])
// Use keyboard state from parent state instead of local state
const { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard } = state
@@ -116,8 +106,7 @@ export function InputPhase() {
const acceptCorrectNumber = useCallback(
(number: number) => {
acceptNumber?.(number)
currentInputRef.current = '' // Clear ref immediately
setInput?.('')
// setInput('') is called inside acceptNumber action creator
setDisplayFeedback('correct')
setTimeout(() => setDisplayFeedback('neutral'), 500)
@@ -127,11 +116,11 @@ export function InputPhase() {
setTimeout(() => showResults?.(), 1000)
}
},
[acceptNumber, setInput, showResults, state.foundNumbers.length, state.correctAnswers.length]
[acceptNumber, showResults, state.foundNumbers.length, state.correctAnswers.length]
)
const handleIncorrectGuess = useCallback(() => {
const wrongNumber = parseInt(currentInputRef.current, 10)
const wrongNumber = parseInt(state.currentInput, 10)
if (!Number.isNaN(wrongNumber)) {
dispatch({ type: 'ADD_WRONG_GUESS_ANIMATION', number: wrongNumber })
// Clear wrong guess animations after explosion
@@ -141,8 +130,7 @@ export function InputPhase() {
}
rejectNumber?.()
currentInputRef.current = '' // Clear ref immediately
setInput?.('')
// setInput('') is called inside rejectNumber action creator
setDisplayFeedback('incorrect')
setTimeout(() => setDisplayFeedback('neutral'), 500)
@@ -151,7 +139,7 @@ export function InputPhase() {
if (state.guessesRemaining - 1 === 0) {
setTimeout(() => showResults?.(), 1000)
}
}, [dispatch, rejectNumber, setInput, showResults, state.guessesRemaining])
}, [state.currentInput, dispatch, rejectNumber, showResults, state.guessesRemaining])
// Simple keyboard event handlers that will be defined after callbacks
const handleKeyboardInput = useCallback(
@@ -161,9 +149,8 @@ export function InputPhase() {
// Only handle if input phase is active and guesses remain
if (state.guessesRemaining === 0) return
// Use ref for immediate value, update ref right away to prevent stale reads
const newInput = currentInputRef.current + key
currentInputRef.current = newInput // Update ref immediately for next keypress
// Update input with new key
const newInput = state.currentInput + key
setInput?.(newInput)
// Clear any existing timeout
@@ -215,9 +202,8 @@ export function InputPhase() {
)
const handleKeyboardBackspace = useCallback(() => {
if (currentInputRef.current.length > 0) {
const newInput = currentInputRef.current.slice(0, -1)
currentInputRef.current = newInput // Update ref immediately
if (state.currentInput.length > 0) {
const newInput = state.currentInput.slice(0, -1)
setInput?.(newInput)
// Clear any existing timeout
@@ -228,7 +214,7 @@ export function InputPhase() {
setDisplayFeedback('neutral')
}
}, [state.prefixAcceptanceTimeout, dispatch, setInput])
}, [state.currentInput, state.prefixAcceptanceTimeout, dispatch, setInput])
// Set up global keyboard listeners
useEffect(() => {
@@ -383,6 +369,172 @@ export function InputPhase() {
</div>
</div>
{/* Live Scoreboard - Competitive Mode Only */}
{state.playMode === 'competitive' &&
state.activePlayers &&
state.activePlayers.length > 1 && (
<div
style={{
marginBottom: '16px',
padding: '12px',
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
borderRadius: '8px',
border: '2px solid #f59e0b',
}}
>
<div
style={{
fontSize: '12px',
fontWeight: 'bold',
color: '#92400e',
marginBottom: '8px',
textAlign: 'center',
}}
>
🏆 LIVE SCOREBOARD
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
{(() => {
// Group players by userId
const userTeams = new Map<
string,
{ userId: string; players: any[]; score: { correct: number; incorrect: number } }
>()
console.log('📊 [InputPhase] Building scoreboard:', {
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
playerScores: state.playerScores,
})
for (const playerId of state.activePlayers) {
const metadata = state.playerMetadata?.[playerId]
const userId = metadata?.userId
console.log('📊 [InputPhase] Processing player for scoreboard:', {
playerId,
metadata,
userId,
})
if (!userId) continue
if (!userTeams.has(userId)) {
userTeams.set(userId, {
userId,
players: [],
score: state.playerScores?.[userId] || { correct: 0, incorrect: 0 },
})
}
userTeams.get(userId)!.players.push(metadata)
}
console.log('📊 [InputPhase] UserTeams created:', {
count: userTeams.size,
teams: Array.from(userTeams.entries()),
})
// Sort teams by score
return Array.from(userTeams.values())
.sort((a, b) => {
const aScore = a.score.correct - a.score.incorrect * 0.5
const bScore = b.score.correct - b.score.incorrect * 0.5
return bScore - aScore
})
.map((team, index) => {
const netScore = team.score.correct - team.score.incorrect * 0.5
return (
<div
key={team.userId}
style={{
padding: '10px 12px',
background: index === 0 ? '#fef3c7' : 'white',
borderRadius: '8px',
border: index === 0 ? '2px solid #f59e0b' : '1px solid #e5e7eb',
}}
>
{/* Team header with rank and stats */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '18px' }}>
{index === 0 ? '👑' : `${index + 1}.`}
</span>
<span
style={{
fontWeight: 'bold',
fontSize: '16px',
color: '#1f2937',
}}
>
Score: {netScore.toFixed(1)}
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '14px',
}}
>
<span style={{ color: '#10b981', fontWeight: 'bold' }}>
{team.score.correct}
</span>
<span style={{ color: '#ef4444', fontWeight: 'bold' }}>
{team.score.incorrect}
</span>
</div>
</div>
{/* Players list */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
paddingLeft: '26px',
}}
>
{team.players.map((player, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
}}
>
<span style={{ fontSize: '16px' }}>{player?.emoji || '🎮'}</span>
<span
style={{
color: '#1f2937',
fontWeight: '500',
}}
>
{player?.name || `Player ${i + 1}`}
</span>
</div>
))}
</div>
</div>
)
})
})()}
</div>
</div>
)}
<div
style={{
position: 'relative',

View File

@@ -80,78 +80,78 @@ export function MemoryQuizGame() {
>
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
padding: '20px 8px',
minHeight: '100vh',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
}}
>
<div
style={{
maxWidth: '100%',
margin: '0 auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
padding: '20px 8px',
minHeight: '100vh',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
}}
>
<div
style={{
maxWidth: '100%',
margin: '0 auto',
className={css({
textAlign: 'center',
mb: '4',
flexShrink: 0,
})}
>
<Link
href="/arcade"
className={css({
display: 'inline-flex',
alignItems: 'center',
color: 'gray.600',
textDecoration: 'none',
mb: '4',
_hover: { color: 'gray.800' },
})}
>
Back to Champion Arena
</Link>
</div>
<div
className={css({
bg: 'white',
rounded: 'xl',
shadow: 'xl',
overflow: 'hidden',
border: '1px solid',
borderColor: 'gray.200',
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
maxHeight: '100%',
})}
>
<div
className={css({
textAlign: 'center',
mb: '4',
flexShrink: 0,
})}
>
<Link
href="/arcade"
className={css({
display: 'inline-flex',
alignItems: 'center',
color: 'gray.600',
textDecoration: 'none',
mb: '4',
_hover: { color: 'gray.800' },
})}
>
Back to Champion Arena
</Link>
</div>
<div
className={css({
bg: 'white',
rounded: 'xl',
shadow: 'xl',
overflow: 'hidden',
border: '1px solid',
borderColor: 'gray.200',
flex: 1,
display: 'flex',
flexDirection: 'column',
maxHeight: '100%',
overflow: 'auto',
})}
>
<div
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
})}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'display' && <DisplayPhase />}
{state.gamePhase === 'input' && <InputPhase key="input-phase" />}
{state.gamePhase === 'results' && <ResultsPhase />}
</div>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'display' && <DisplayPhase />}
{state.gamePhase === 'input' && <InputPhase key="input-phase" />}
{state.gamePhase === 'results' && <ResultsPhase />}
</div>
</div>
</div>
</div>
</PageWithNav>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,17 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import type { GameMove } from '@/lib/arcade/validation'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import {
buildPlayerMetadata as buildPlayerMetadataUtil,
buildPlayerOwnershipFromRoomData,
} from '@/lib/arcade/player-ownership.client'
import { initialState } from '../reducer'
import type { QuizCard, SorobanQuizState } from '../types'
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
@@ -45,6 +51,25 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
const cardCount = quizCards.length
// Initialize player scores for all active players (by userId, not playerId)
const activePlayers = move.data.activePlayers || []
const playerMetadata = move.data.playerMetadata || {}
// Extract unique userIds from playerMetadata
const uniqueUserIds = new Set<string>()
for (const playerId of activePlayers) {
const metadata = playerMetadata[playerId]
if (metadata?.userId) {
uniqueUserIds.add(metadata.userId)
}
}
// Initialize scores for each userId
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
}, {})
return {
...state,
quizCards,
@@ -57,6 +82,10 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
// Multiplayer state
activePlayers,
playerMetadata,
playerScores,
}
}
@@ -72,26 +101,53 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
gamePhase: 'input',
}
case 'ACCEPT_NUMBER':
case 'ACCEPT_NUMBER': {
// Track scores by userId (not playerId) since we can't determine which player typed
// Defensive check: ensure state properties exist
const playerScores = state.playerScores || {}
const foundNumbers = state.foundNumbers || []
const numberFoundBy = state.numberFoundBy || {}
const newPlayerScores = { ...playerScores }
const newNumberFoundBy = { ...numberFoundBy }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
...currentScore,
correct: currentScore.correct + 1,
}
// Track who found this number
newNumberFoundBy[move.data.number] = move.userId
}
return {
...state,
foundNumbers: [...state.foundNumbers, move.data.number],
currentInput: '',
foundNumbers: [...foundNumbers, move.data.number],
playerScores: newPlayerScores,
numberFoundBy: newNumberFoundBy,
}
}
case 'REJECT_NUMBER':
case 'REJECT_NUMBER': {
// Track scores by userId (not playerId) since we can't determine which player typed
// Defensive check: ensure state properties exist
const playerScores = state.playerScores || {}
const newPlayerScores = { ...playerScores }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
}
case 'SET_INPUT':
return {
...state,
currentInput: move.data.input,
playerScores: newPlayerScores,
}
}
case 'SHOW_RESULTS':
return {
@@ -117,7 +173,7 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
case 'SET_CONFIG': {
const { field, value } = move.data as {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty'
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
value: any
}
return {
@@ -140,6 +196,41 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active player IDs as array
const activePlayers = Array.from(activePlayerIds)
// LOCAL-ONLY state for current input (not synced over network)
// This prevents sending a network request for every keystroke
const [localCurrentInput, setLocalCurrentInput] = useState('')
// Merge saved game config from room with initialState
// Settings are scoped by game name to preserve settings when switching games
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
if (!gameConfig) {
return initialState
}
// Get settings for this specific game (memory-quiz)
const savedConfig = gameConfig['memory-quiz'] as Record<string, any> | null | undefined
if (!savedConfig) {
return initialState
}
return {
...initialState,
// Restore settings from saved config
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
displayTime: savedConfig.displayTime ?? initialState.displayTime,
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig.playMode ?? initialState.playMode,
}
}, [roomData?.gameConfig])
// Arcade session integration WITH room sync
const {
@@ -150,10 +241,17 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
} = useArcadeSession<SorobanQuizState>({
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
initialState,
initialState: mergedInitialState,
applyMove: applyMoveOptimistically,
})
// Clear local input when game phase changes or when game resets
useEffect(() => {
if (state.gamePhase !== 'input') {
setLocalCurrentInput('')
}
}, [state.gamePhase])
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
@@ -163,33 +261,53 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
}
}, [state.prefixAcceptanceTimeout])
// Detect state corruption/mismatch (e.g., game type mismatch between sessions)
const hasStateCorruption =
!state.quizCards ||
!state.correctAnswers ||
!state.foundNumbers ||
!Array.isArray(state.quizCards)
// Computed values
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
// Build player metadata from room data and player map
const buildPlayerMetadata = useCallback(() => {
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
const metadata = buildPlayerMetadataUtil(activePlayers, playerOwnership, players, viewerId)
return metadata
}, [activePlayers, players, roomData, viewerId])
// Action creators - send moves to arcade session
// For single-player quiz, we use viewerId as playerId
const startQuiz = useCallback(
(quizCards: QuizCard[]) => {
// Extract only serializable data (numbers) for server
// React components can't be sent over Socket.IO
const numbers = quizCards.map((card) => card.number)
// Build player metadata for multiplayer
const playerMetadata = buildPlayerMetadata()
sendMove({
type: 'START_QUIZ',
playerId: viewerId || '',
playerId: TEAM_MOVE, // Team move - all players act together
userId: viewerId || '', // User who initiated
data: {
numbers, // Send to server
quizCards, // Keep for optimistic local update
activePlayers, // Send active players list
playerMetadata, // Send player display info
},
})
},
[viewerId, sendMove]
[viewerId, sendMove, activePlayers, buildPlayerMetadata]
)
const nextCard = useCallback(() => {
sendMove({
type: 'NEXT_CARD',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
@@ -197,16 +315,21 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const showInputPhase = useCallback(() => {
sendMove({
type: 'SHOW_INPUT_PHASE',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const acceptNumber = useCallback(
(number: number) => {
// Clear local input immediately
setLocalCurrentInput('')
sendMove({
type: 'ACCEPT_NUMBER',
playerId: viewerId || '',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed correctly
data: { number },
})
},
@@ -214,28 +337,28 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
)
const rejectNumber = useCallback(() => {
// Clear local input immediately
setLocalCurrentInput('')
sendMove({
type: 'REJECT_NUMBER',
playerId: viewerId || '',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed incorrectly
data: {},
})
}, [viewerId, sendMove])
const setInput = useCallback(
(input: string) => {
sendMove({
type: 'SET_INPUT',
playerId: viewerId || '',
data: { input },
})
},
[viewerId, sendMove]
)
const setInput = useCallback((input: string) => {
// LOCAL ONLY - no network sync!
// This makes typing instant with zero network lag
setLocalCurrentInput(input)
}, [])
const showResults = useCallback(() => {
sendMove({
type: 'SHOW_RESULTS',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
@@ -243,24 +366,151 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const resetGame = useCallback(() => {
sendMove({
type: 'RESET_QUIZ',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const setConfig = useCallback(
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => {
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode', value: any) => {
console.log(`[RoomMemoryQuizProvider] setConfig called: ${field} = ${value}`)
sendMove({
type: 'SET_CONFIG',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { field, value },
})
// Save setting to room's gameConfig for persistence
// Settings are scoped by game name to preserve settings when switching games
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentMemoryQuizConfig =
(currentGameConfig['memory-quiz'] as Record<string, any>) || {}
updateGameConfig({
roomId: roomData.id,
gameConfig: {
...currentGameConfig,
'memory-quiz': {
...currentMemoryQuizConfig,
[field]: value,
},
},
})
}
},
[viewerId, sendMove]
[viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
// Merge network state with local input state
const mergedState = {
...state,
currentInput: localCurrentInput, // Override network state with local input
}
// If state is corrupted, show error message instead of crashing
if (hasStateCorruption) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
textAlign: 'center',
minHeight: '400px',
}}
>
<div
style={{
fontSize: '48px',
marginBottom: '20px',
}}
>
</div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#dc2626',
}}
>
Game State Mismatch
</h2>
<p
style={{
fontSize: '16px',
color: '#6b7280',
marginBottom: '24px',
maxWidth: '500px',
}}
>
There's a mismatch between game types in this room. This usually happens when room members
are playing different games.
</p>
<div
style={{
background: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px',
maxWidth: '500px',
}}
>
<p
style={{
fontSize: '14px',
fontWeight: '600',
marginBottom: '8px',
}}
>
To fix this:
</p>
<ol
style={{
fontSize: '14px',
textAlign: 'left',
paddingLeft: '20px',
lineHeight: '1.6',
}}
>
<li>Make sure all room members are on the same game page</li>
<li>Try refreshing the page</li>
<li>If the issue persists, leave and rejoin the room</li>
</ol>
</div>
<button
onClick={() => window.location.reload()}
style={{
padding: '10px 20px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Refresh Page
</button>
</div>
)
}
// Determine if current user is the room creator (controls card timing)
const isRoomCreator =
roomData?.members.find((member) => member.userId === viewerId)?.isCreator || false
const contextValue: MemoryQuizContextValue = {
state,
state: mergedState,
dispatch: () => {
// No-op - replaced with action creators
console.warn('dispatch() is deprecated in room mode, use action creators instead')
@@ -268,6 +518,7 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
isGameActive,
resetGame,
exitSession,
isRoomCreator, // Pass room creator flag to components
// Expose action creators for components to use
startQuiz,
nextCard,

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,5 +15,6 @@ export * from './room-invitations'
export * from './room-reports'
export * from './room-bans'
export * from './room-join-requests'
export * from './room-game-configs'
export * from './user-stats'
export * from './users'

View File

@@ -0,0 +1,48 @@
import { createId } from '@paralleldrive/cuid2'
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { arcadeRooms } from './arcade-rooms'
/**
* Game-specific configuration settings for arcade rooms
* Each row represents one game's settings for one room
*/
export const roomGameConfigs = sqliteTable(
'room_game_configs',
{
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
// Room reference
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
// Game identifier
gameName: text('game_name', {
enum: ['matching', 'memory-quiz', 'complement-race'],
}).notNull(),
// Game-specific configuration JSON
// Structure depends on gameName:
// - matching: { gameType, difficulty, turnTimer }
// - memory-quiz: { selectedCount, displayTime, selectedDifficulty, playMode }
// - complement-race: TBD
config: text('config', { mode: 'json' }).notNull(),
// Timestamps
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
// Ensure only one config per game per room
uniqueRoomGame: uniqueIndex('room_game_idx').on(table.roomId, table.gameName),
})
)
export type RoomGameConfig = typeof roomGameConfigs.$inferSelect
export type NewRoomGameConfig = typeof roomGameConfigs.$inferInsert

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,200 @@
/**
* Game configuration helpers
*
* Centralized functions for reading and writing game configs from the database.
* Uses the room_game_configs table (one row per game per room).
*/
import { and, eq } from 'drizzle-orm'
import { createId } from '@paralleldrive/cuid2'
import { db, schema } from '@/db'
import type { GameName } from './validation'
import type { GameConfigByName } from './game-configs'
import {
DEFAULT_MATCHING_CONFIG,
DEFAULT_MEMORY_QUIZ_CONFIG,
DEFAULT_COMPLEMENT_RACE_CONFIG,
} from './game-configs'
/**
* Get default config for a game
*/
function getDefaultGameConfig(gameName: GameName): GameConfigByName[GameName] {
switch (gameName) {
case 'matching':
return DEFAULT_MATCHING_CONFIG
case 'memory-quiz':
return DEFAULT_MEMORY_QUIZ_CONFIG
case 'complement-race':
return DEFAULT_COMPLEMENT_RACE_CONFIG
default:
throw new Error(`Unknown game: ${gameName}`)
}
}
/**
* Get game-specific config from database with defaults
* Type-safe: returns the correct config type based on gameName
*/
export async function getGameConfig<T extends GameName>(
roomId: string,
gameName: T
): Promise<GameConfigByName[T]> {
// Query the room_game_configs table for this specific room+game
const configRow = await db.query.roomGameConfigs.findFirst({
where: and(
eq(schema.roomGameConfigs.roomId, roomId),
eq(schema.roomGameConfigs.gameName, gameName)
),
})
// If no config exists, return defaults
if (!configRow) {
return getDefaultGameConfig(gameName) as GameConfigByName[T]
}
// Merge saved config with defaults to handle missing fields
const defaults = getDefaultGameConfig(gameName)
return { ...defaults, ...(configRow.config as object) } as GameConfigByName[T]
}
/**
* Set (upsert) a game's config in the database
* Creates a new row if it doesn't exist, updates if it does
*/
export async function setGameConfig<T extends GameName>(
roomId: string,
gameName: T,
config: Partial<GameConfigByName[T]>
): Promise<void> {
const now = new Date()
// Check if config already exists
const existing = await db.query.roomGameConfigs.findFirst({
where: and(
eq(schema.roomGameConfigs.roomId, roomId),
eq(schema.roomGameConfigs.gameName, gameName)
),
})
if (existing) {
// Update existing config (merge with existing values)
const mergedConfig = { ...(existing.config as object), ...config }
await db
.update(schema.roomGameConfigs)
.set({
config: mergedConfig as any,
updatedAt: now,
})
.where(eq(schema.roomGameConfigs.id, existing.id))
} else {
// Insert new config (merge with defaults)
const defaults = getDefaultGameConfig(gameName)
const mergedConfig = { ...defaults, ...config }
await db.insert(schema.roomGameConfigs).values({
id: createId(),
roomId,
gameName,
config: mergedConfig as any,
createdAt: now,
updatedAt: now,
})
}
console.log(`[GameConfig] Updated ${gameName} config for room ${roomId}`)
}
/**
* Update a specific field in a game's config
* Convenience wrapper around setGameConfig
*/
export async function updateGameConfigField<
T extends GameName,
K extends keyof GameConfigByName[T],
>(roomId: string, gameName: T, field: K, value: GameConfigByName[T][K]): Promise<void> {
// Create a partial config with just the field being updated
const partialConfig: Partial<GameConfigByName[T]> = {} as any
;(partialConfig as any)[field] = value
await setGameConfig(roomId, gameName, partialConfig)
}
/**
* Delete a game's config from the database
* Useful when clearing game selection or cleaning up
*/
export async function deleteGameConfig(roomId: string, gameName: GameName): Promise<void> {
await db
.delete(schema.roomGameConfigs)
.where(
and(eq(schema.roomGameConfigs.roomId, roomId), eq(schema.roomGameConfigs.gameName, gameName))
)
console.log(`[GameConfig] Deleted ${gameName} config for room ${roomId}`)
}
/**
* Get all game configs for a room (all games)
* Returns a map of gameName -> config
*/
export async function getAllGameConfigs(
roomId: string
): Promise<Partial<Record<GameName, unknown>>> {
const configs = await db.query.roomGameConfigs.findMany({
where: eq(schema.roomGameConfigs.roomId, roomId),
})
const result: Partial<Record<GameName, unknown>> = {}
for (const config of configs) {
result[config.gameName as GameName] = config.config
}
return result
}
/**
* Delete all game configs for a room
* Called when deleting a room (cascade should handle this, but useful for explicit cleanup)
*/
export async function deleteAllGameConfigs(roomId: string): Promise<void> {
await db.delete(schema.roomGameConfigs).where(eq(schema.roomGameConfigs.roomId, roomId))
console.log(`[GameConfig] Deleted all configs for room ${roomId}`)
}
/**
* Validate a game config at runtime
* Returns true if the config is valid for the given game
*/
export function validateGameConfig(gameName: GameName, config: any): boolean {
switch (gameName) {
case 'matching':
return (
typeof config === 'object' &&
config !== null &&
['abacus-numeral', 'complement-pairs'].includes(config.gameType) &&
typeof config.difficulty === 'number' &&
[6, 8, 12, 15].includes(config.difficulty) &&
typeof config.turnTimer === 'number' &&
config.turnTimer >= 5 &&
config.turnTimer <= 300
)
case 'memory-quiz':
return (
typeof config === 'object' &&
config !== null &&
[2, 5, 8, 12, 15].includes(config.selectedCount) &&
typeof config.displayTime === 'number' &&
config.displayTime > 0 &&
['beginner', 'easy', 'medium', 'hard', 'expert'].includes(config.selectedDifficulty) &&
['cooperative', 'competitive'].includes(config.playMode)
)
case 'complement-race':
// TODO: Add validation when complement-race settings are defined
return typeof config === 'object' && config !== null
default:
return false
}
}

View File

@@ -0,0 +1,80 @@
/**
* Shared game configuration types
*
* This is the single source of truth for all game settings.
* These types are used across:
* - Database storage (room_game_configs table)
* - Validators (getInitialState method signatures)
* - Client providers (settings UI and state management)
* - Helper functions (reading/writing configs)
*/
import type { DifficultyLevel } from '@/app/arcade/memory-quiz/types'
import type { Difficulty, GameType } from '@/app/games/matching/context/types'
/**
* Configuration for matching (memory pairs) game
*/
export interface MatchingGameConfig {
gameType: GameType
difficulty: Difficulty
turnTimer: number
}
/**
* Configuration for memory-quiz (soroban lightning) game
*/
export interface MemoryQuizGameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
}
/**
* Configuration for complement-race game
* TODO: Define when implementing complement-race settings
*/
export interface ComplementRaceGameConfig {
// Future settings will go here
placeholder?: never
}
/**
* Union type of all game configs for type-safe access
*/
export type GameConfigByName = {
matching: MatchingGameConfig
'memory-quiz': MemoryQuizGameConfig
'complement-race': ComplementRaceGameConfig
}
/**
* Room's game configuration object (nested by game name)
* This matches the structure stored in room_game_configs table
*/
export interface RoomGameConfig {
matching?: MatchingGameConfig
'memory-quiz'?: MemoryQuizGameConfig
'complement-race'?: ComplementRaceGameConfig
}
/**
* Default configurations for each game
*/
export const DEFAULT_MATCHING_CONFIG: MatchingGameConfig = {
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
}
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: 'easy',
playMode: 'cooperative',
}
export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
// Future defaults will go here
}

View File

@@ -3,15 +3,10 @@
* Validates all game moves and state transitions
*/
import type {
Difficulty,
GameCard,
GameType,
MemoryPairsState,
Player,
} from '@/app/games/matching/context/types'
import type { GameCard, MemoryPairsState, Player } from '@/app/games/matching/context/types'
import { generateGameCards } from '@/app/games/matching/utils/cardGeneration'
import { canFlipCard, validateMatch } from '@/app/games/matching/utils/matchValidation'
import type { MatchingGameConfig } from '@/lib/arcade/game-configs'
import type { GameValidator, MatchingGameMove, ValidationResult } from './types'
export class MatchingGameValidator implements GameValidator<MemoryPairsState, MatchingGameMove> {
@@ -536,11 +531,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
}
getInitialState(config: {
difficulty: Difficulty
gameType: GameType
turnTimer: number
}): MemoryPairsState {
getInitialState(config: MatchingGameConfig): MemoryPairsState {
return {
cards: [],
gameCards: [],

View File

@@ -3,7 +3,8 @@
* Validates all game moves and state transitions
*/
import type { DifficultyLevel, SorobanQuizState } from '@/app/arcade/memory-quiz/types'
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
import type {
GameValidator,
MemoryQuizGameMove,
@@ -30,10 +31,10 @@ export class MemoryQuizGameValidator
return this.validateShowInputPhase(state)
case 'ACCEPT_NUMBER':
return this.validateAcceptNumber(state, move.data.number)
return this.validateAcceptNumber(state, move.data.number, move.userId)
case 'REJECT_NUMBER':
return this.validateRejectNumber(state)
return this.validateRejectNumber(state, move.userId)
case 'SET_INPUT':
return this.validateSetInput(state, move.data.input)
@@ -83,6 +84,24 @@ export class MemoryQuizGameValidator
element: null,
}))
// Extract multiplayer data from move
const activePlayers = data.activePlayers || state.activePlayers || []
const playerMetadata = data.playerMetadata || state.playerMetadata || {}
// Initialize player scores for all active players (by userId)
const uniqueUserIds = new Set<string>()
for (const playerId of activePlayers) {
const metadata = playerMetadata[playerId]
if (metadata?.userId) {
uniqueUserIds.add(metadata.userId)
}
}
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
}, {})
const newState: SorobanQuizState = {
...state,
quizCards,
@@ -95,6 +114,11 @@ export class MemoryQuizGameValidator
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
// Multiplayer state
activePlayers,
playerMetadata,
playerScores,
numberFoundBy: {},
}
return {
@@ -143,7 +167,11 @@ export class MemoryQuizGameValidator
}
}
private validateAcceptNumber(state: SorobanQuizState, number: number): ValidationResult {
private validateAcceptNumber(
state: SorobanQuizState,
number: number,
userId?: string
): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
@@ -153,12 +181,6 @@ export class MemoryQuizGameValidator
}
// 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,
@@ -174,10 +196,28 @@ export class MemoryQuizGameValidator
}
}
// Update player scores (track by userId)
const playerScores = state.playerScores || {}
const newPlayerScores = { ...playerScores }
const numberFoundBy = state.numberFoundBy || {}
const newNumberFoundBy = { ...numberFoundBy }
if (userId) {
const currentScore = newPlayerScores[userId] || { correct: 0, incorrect: 0 }
newPlayerScores[userId] = {
...currentScore,
correct: currentScore.correct + 1,
}
// Track who found this number
newNumberFoundBy[number] = userId
}
const newState: SorobanQuizState = {
...state,
foundNumbers: [...state.foundNumbers, number],
currentInput: '',
playerScores: newPlayerScores,
numberFoundBy: newNumberFoundBy,
}
return {
@@ -186,7 +226,7 @@ export class MemoryQuizGameValidator
}
}
private validateRejectNumber(state: SorobanQuizState): ValidationResult {
private validateRejectNumber(state: SorobanQuizState, userId?: string): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
@@ -203,11 +243,23 @@ export class MemoryQuizGameValidator
}
}
// Update player scores (track by userId)
const playerScores = state.playerScores || {}
const newPlayerScores = { ...playerScores }
if (userId) {
const currentScore = newPlayerScores[userId] || { correct: 0, incorrect: 0 }
newPlayerScores[userId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
const newState: SorobanQuizState = {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
playerScores: newPlayerScores,
}
return {
@@ -289,7 +341,7 @@ export class MemoryQuizGameValidator
private validateSetConfig(
state: SorobanQuizState,
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty',
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: any
): ValidationResult {
// Can only change config during setup phase
@@ -320,6 +372,12 @@ export class MemoryQuizGameValidator
}
break
case 'playMode':
if (!['cooperative', 'competitive'].includes(value)) {
return { valid: false, error: `Invalid playMode: ${value}` }
}
break
default:
return { valid: false, error: `Unknown config field: ${field}` }
}
@@ -338,11 +396,7 @@ export class MemoryQuizGameValidator
return state.gamePhase === 'results'
}
getInitialState(config: {
selectedCount: number
displayTime: number
selectedDifficulty: DifficultyLevel
}): SorobanQuizState {
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
return {
cards: [],
quizCards: [],
@@ -355,6 +409,13 @@ export class MemoryQuizGameValidator
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
// Multiplayer state
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: config.playMode || 'cooperative',
numberFoundBy: {},
// UI state
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,

View File

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

View File

@@ -15,6 +15,7 @@ import { getRoomMembers, getUserRooms, setMemberOnline } from './lib/arcade/room
import { getRoomActivePlayers, getRoomPlayerIds } from './lib/arcade/player-manager'
import type { GameMove, GameName } from './lib/arcade/validation'
import { getValidator } from './lib/arcade/validation'
import { getGameConfig } from './lib/arcade/game-config-helpers'
// Use globalThis to store socket.io instance to avoid module isolation issues
// This ensures the same instance is accessible across dynamic imports
@@ -81,24 +82,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
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 || {})
}
// Get game-specific config from database (type-safe)
const gameConfig = await getGameConfig(roomId, room.gameName as GameName)
const initialState = validator.getInitialState(gameConfig)
session = await createArcadeSession({
userId,

View File

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