Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
860fd607be | ||
|
|
3bae00b9a9 | ||
|
|
ff791409cf | ||
|
|
c1be0277c1 | ||
|
|
04c9944f2e | ||
|
|
260bdc2e9d | ||
|
|
8dbdc837cc | ||
|
|
1bd73544df | ||
|
|
506bfeccf2 | ||
|
|
38e554e6ea | ||
|
|
8f8f112de2 | ||
|
|
f3080b50d9 | ||
|
|
de0efd5932 | ||
|
|
c9e5c473e6 | ||
|
|
487ca7fba6 | ||
|
|
8f7eebce4b | ||
|
|
94ef39234d | ||
|
|
6d14dd8b47 | ||
|
|
0ee7739091 | ||
|
|
5c135358fc | ||
|
|
74554c3669 | ||
|
|
a89d3a9701 | ||
|
|
180e213d00 | ||
|
|
c33698ce52 | ||
|
|
5b4cb7d35a | ||
|
|
eacbafb1ea | ||
|
|
08fe4326a6 | ||
|
|
fabb33252c | ||
|
|
00dcb872b7 | ||
|
|
ea23651cb6 | ||
|
|
2273c71a87 | ||
|
|
9cb5fdd2fa | ||
|
|
73c54a7ebc | ||
|
|
7cea297095 | ||
|
|
019d36a0ab | ||
|
|
1922b2122b | ||
|
|
3dfe54f1cb | ||
|
|
5f04a3b622 | ||
|
|
05a8e0a842 | ||
|
|
9dac9b7a36 | ||
|
|
b99e754395 | ||
|
|
3eaa84d157 | ||
|
|
51676fc15f | ||
|
|
82ca31029c | ||
|
|
472f201088 | ||
|
|
86b75cba5a | ||
|
|
a93d981d1a | ||
|
|
05bd11a133 | ||
|
|
1cf44696c2 |
167
CHANGELOG.md
167
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
421
apps/web/.claude/GAME_SETTINGS_PERSISTENCE.md
Normal file
421
apps/web/.claude/GAME_SETTINGS_PERSISTENCE.md
Normal 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
|
||||
479
apps/web/.claude/GAME_SETTINGS_REFACTORING.md
Normal file
479
apps/web/.claude/GAME_SETTINGS_REFACTORING.md
Normal 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
|
||||
120
apps/web/.claude/MANUAL_MIGRATION_0011.md
Normal file
120
apps/web/.claude/MANUAL_MIGRATION_0011.md
Normal 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
|
||||
@@ -69,7 +69,8 @@
|
||||
"Bash(git reset:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(killall:*)",
|
||||
"Bash(echo:*)"
|
||||
"Bash(echo:*)",
|
||||
"Bash(git restore:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
33
apps/web/drizzle/0011_add_room_game_configs.sql
Normal file
33
apps/web/drizzle/0011_add_room_game_configs.sql
Normal 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');
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
48
apps/web/src/db/schema/room-game-configs.ts
Normal file
48
apps/web/src/db/schema/room-game-configs.ts
Normal 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
|
||||
@@ -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: {},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -260,6 +260,7 @@ describe('session-manager', () => {
|
||||
type: 'FLIP_CARD',
|
||||
data: { cardId: '1' },
|
||||
playerId: '1',
|
||||
userId: mockUserId,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
|
||||
200
apps/web/src/lib/arcade/game-config-helpers.ts
Normal file
200
apps/web/src/lib/arcade/game-config-helpers.ts
Normal 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
|
||||
}
|
||||
}
|
||||
80
apps/web/src/lib/arcade/game-configs.ts
Normal file
80
apps/web/src/lib/arcade/game-configs.ts
Normal 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
|
||||
}
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.14.4",
|
||||
"version": "3.18.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user