Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eed26966c | ||
|
|
49219e34cd | ||
|
|
499ee525a8 | ||
|
|
843b45b14e | ||
|
|
76a8472f12 | ||
|
|
bf02bc14fd | ||
|
|
ffb626f403 | ||
|
|
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 |
137
CHANGELOG.md
137
CHANGELOG.md
@@ -1,3 +1,140 @@
|
||||
## [3.20.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.19.0...v3.20.0) (2025-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* adjust tier probabilities for more abacus flavor ([49219e3](https://github.com/antialias/soroban-abacus-flashcards/commit/49219e34cde32736155a11929d10581e783cba69))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* use per-word-type tier selection for name generation ([499ee52](https://github.com/antialias/soroban-abacus-flashcards/commit/499ee525a835249b439044cf602bf9f0ff322cec))
|
||||
|
||||
## [3.19.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.18.1...v3.19.0) (2025-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement avatar-themed name generation with probabilistic mixing ([76a8472](https://github.com/antialias/soroban-abacus-flashcards/commit/76a8472f12d251071b97f2288f62f0b358576232))
|
||||
|
||||
## [3.18.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.18.0...v3.18.1) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** prevent empty update in settings API when only gameConfig changes ([ffb626f](https://github.com/antialias/soroban-abacus-flashcards/commit/ffb626f4038fd32d0f40dba8d83ae4d881d698d0))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -70,7 +70,12 @@
|
||||
"Bash(lsof:*)",
|
||||
"Bash(killall:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(git restore:*)"
|
||||
"Bash(git restore:*)",
|
||||
"Bash(timeout 10 npm run dev:*)",
|
||||
"Bash(timeout 30 npm run dev)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(for i in {1..30})",
|
||||
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run ID: \\(.databaseId)\"\"')"
|
||||
],
|
||||
"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) {
|
||||
@@ -104,12 +148,30 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, roomId))
|
||||
}
|
||||
|
||||
// Update room settings
|
||||
const [updatedRoom] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set(updateData)
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
.returning()
|
||||
// Update room settings (only if there's something to update)
|
||||
let updatedRoom = currentRoom
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
;[updatedRoom] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set(updateData)
|
||||
.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) {
|
||||
@@ -117,11 +179,17 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
if (io) {
|
||||
try {
|
||||
console.log(`[Settings API] Broadcasting game change to room ${roomId}: ${body.gameName}`)
|
||||
io.to(`room:${roomId}`).emit('room-game-changed', {
|
||||
const broadcastData: {
|
||||
roomId: string
|
||||
gameName: string | null
|
||||
gameConfig?: Record<string, unknown>
|
||||
} = {
|
||||
roomId,
|
||||
gameName: body.gameName,
|
||||
gameConfig: body.gameConfig || {},
|
||||
})
|
||||
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)
|
||||
}
|
||||
@@ -194,7 +262,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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
@@ -240,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)
|
||||
@@ -247,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 {
|
||||
@@ -259,7 +329,7 @@ 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,
|
||||
})
|
||||
|
||||
@@ -498,6 +568,8 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
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({
|
||||
@@ -506,12 +578,45 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
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, viewerId]
|
||||
[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',
|
||||
@@ -519,12 +624,45 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
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, viewerId]
|
||||
[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',
|
||||
@@ -532,8 +670,39 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
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, viewerId]
|
||||
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
|
||||
@@ -55,35 +55,21 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
|
||||
const activePlayers = move.data.activePlayers || []
|
||||
const playerMetadata = move.data.playerMetadata || {}
|
||||
|
||||
console.log('🎯 [START_QUIZ] Initializing player scores:', {
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
})
|
||||
|
||||
// Extract unique userIds from playerMetadata
|
||||
const uniqueUserIds = new Set<string>()
|
||||
for (const playerId of activePlayers) {
|
||||
const metadata = playerMetadata[playerId]
|
||||
console.log('🎯 [START_QUIZ] Processing player:', {
|
||||
playerId,
|
||||
metadata,
|
||||
hasUserId: !!metadata?.userId,
|
||||
})
|
||||
if (metadata?.userId) {
|
||||
uniqueUserIds.add(metadata.userId)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎯 [START_QUIZ] Unique userIds found:', Array.from(uniqueUserIds))
|
||||
|
||||
// Initialize scores for each userId
|
||||
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
|
||||
acc[userId] = { correct: 0, incorrect: 0 }
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
console.log('🎯 [START_QUIZ] Initialized playerScores:', playerScores)
|
||||
|
||||
return {
|
||||
...state,
|
||||
quizCards,
|
||||
@@ -122,12 +108,6 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
|
||||
const foundNumbers = state.foundNumbers || []
|
||||
const numberFoundBy = state.numberFoundBy || {}
|
||||
|
||||
console.log('✅ [ACCEPT_NUMBER] Before update:', {
|
||||
moveUserId: move.userId,
|
||||
currentPlayerScores: playerScores,
|
||||
number: move.data.number,
|
||||
})
|
||||
|
||||
const newPlayerScores = { ...playerScores }
|
||||
const newNumberFoundBy = { ...numberFoundBy }
|
||||
|
||||
@@ -139,15 +119,6 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
|
||||
}
|
||||
// Track who found this number
|
||||
newNumberFoundBy[move.data.number] = move.userId
|
||||
|
||||
console.log('✅ [ACCEPT_NUMBER] After update:', {
|
||||
userId: move.userId,
|
||||
newScore: newPlayerScores[move.userId],
|
||||
allScores: newPlayerScores,
|
||||
numberFoundBy: move.data.number,
|
||||
})
|
||||
} else {
|
||||
console.warn('⚠️ [ACCEPT_NUMBER] No userId in move!')
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
@@ -162,11 +133,6 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
|
||||
// Defensive check: ensure state properties exist
|
||||
const playerScores = state.playerScores || {}
|
||||
|
||||
console.log('❌ [REJECT_NUMBER] Before update:', {
|
||||
moveUserId: move.userId,
|
||||
currentPlayerScores: playerScores,
|
||||
})
|
||||
|
||||
const newPlayerScores = { ...playerScores }
|
||||
if (move.userId) {
|
||||
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
|
||||
@@ -174,13 +140,6 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
|
||||
...currentScore,
|
||||
incorrect: currentScore.incorrect + 1,
|
||||
}
|
||||
console.log('❌ [REJECT_NUMBER] After update:', {
|
||||
userId: move.userId,
|
||||
newScore: newPlayerScores[move.userId],
|
||||
allScores: newPlayerScores,
|
||||
})
|
||||
} else {
|
||||
console.warn('⚠️ [REJECT_NUMBER] No userId in move!')
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
@@ -251,13 +210,17 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
// 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
|
||||
|
||||
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
|
||||
|
||||
console.log('[RoomMemoryQuizProvider] Loading saved game config for memory-quiz:', savedConfig)
|
||||
if (!savedConfig) {
|
||||
return initialState
|
||||
}
|
||||
|
||||
return {
|
||||
...initialState,
|
||||
@@ -310,19 +273,8 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Build player metadata from room data and player map
|
||||
const buildPlayerMetadata = useCallback(() => {
|
||||
console.log('🔍 [buildPlayerMetadata] Starting:', {
|
||||
roomData: roomData?.id,
|
||||
activePlayers,
|
||||
viewerId,
|
||||
playersMapSize: players.size,
|
||||
})
|
||||
|
||||
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
|
||||
console.log('🔍 [buildPlayerMetadata] Player ownership:', playerOwnership)
|
||||
|
||||
const metadata = buildPlayerMetadataUtil(activePlayers, playerOwnership, players, viewerId)
|
||||
console.log('🔍 [buildPlayerMetadata] Built metadata:', metadata)
|
||||
|
||||
return metadata
|
||||
}, [activePlayers, players, roomData, viewerId])
|
||||
|
||||
@@ -336,13 +288,6 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
// Build player metadata for multiplayer
|
||||
const playerMetadata = buildPlayerMetadata()
|
||||
|
||||
console.log('🚀 [startQuiz] Sending START_QUIZ move:', {
|
||||
viewerId,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
numbers,
|
||||
})
|
||||
|
||||
sendMove({
|
||||
type: 'START_QUIZ',
|
||||
playerId: TEAM_MOVE, // Team move - all players act together
|
||||
@@ -381,11 +326,6 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
// Clear local input immediately
|
||||
setLocalCurrentInput('')
|
||||
|
||||
console.log('🚀 [acceptNumber] Sending ACCEPT_NUMBER move:', {
|
||||
viewerId,
|
||||
number,
|
||||
})
|
||||
|
||||
sendMove({
|
||||
type: 'ACCEPT_NUMBER',
|
||||
playerId: TEAM_MOVE, // Team move - can't identify specific player
|
||||
@@ -400,10 +340,6 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
// Clear local input immediately
|
||||
setLocalCurrentInput('')
|
||||
|
||||
console.log('🚀 [rejectNumber] Sending REJECT_NUMBER move:', {
|
||||
viewerId,
|
||||
})
|
||||
|
||||
sendMove({
|
||||
type: 'REJECT_NUMBER',
|
||||
playerId: TEAM_MOVE, // Team move - can't identify specific player
|
||||
@@ -438,6 +374,8 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const setConfig = useCallback(
|
||||
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode', value: any) => {
|
||||
console.log(`[RoomMemoryQuizProvider] setConfig called: ${field} = ${value}`)
|
||||
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: TEAM_MOVE,
|
||||
@@ -452,17 +390,15 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
const currentMemoryQuizConfig =
|
||||
(currentGameConfig['memory-quiz'] as Record<string, any>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
'memory-quiz': {
|
||||
...currentMemoryQuizConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
console.log('[RoomMemoryQuizProvider] Saving game config for memory-quiz:', updatedConfig)
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
gameConfig: {
|
||||
...currentGameConfig,
|
||||
'memory-quiz': {
|
||||
...currentMemoryQuizConfig,
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
const handleGenerateNewName = () => {
|
||||
const allPlayers = Array.from(players.values())
|
||||
const existingNames = allPlayers.filter((p) => p.id !== playerId).map((p) => p.name)
|
||||
const newName = generateUniquePlayerName(existingNames)
|
||||
const newName = generateUniquePlayerName(existingNames, player.emoji)
|
||||
|
||||
setLocalName(newName)
|
||||
updatePlayer(playerId, { name: newName })
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@/hooks/useUserPlayers'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { getNextPlayerColor } from '../types/player'
|
||||
import { generateUniquePlayerName, generateUniquePlayerNames } from '../utils/playerNames'
|
||||
import { generateUniquePlayerName } from '../utils/playerNames'
|
||||
|
||||
// Client-side Player type (compatible with old type)
|
||||
export interface Player {
|
||||
@@ -141,8 +141,13 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isInitialized) {
|
||||
if (dbPlayers.length === 0) {
|
||||
// Generate unique names for default players
|
||||
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
|
||||
// Generate unique names for default players, themed by their emoji
|
||||
const existingNames: string[] = []
|
||||
const generatedNames = DEFAULT_PLAYER_CONFIGS.map((config) => {
|
||||
const name = generateUniquePlayerName(existingNames, config.emoji)
|
||||
existingNames.push(name)
|
||||
return name
|
||||
})
|
||||
|
||||
// Create default players with generated names
|
||||
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
|
||||
@@ -167,10 +172,11 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const addPlayer = (playerData?: Partial<Player>) => {
|
||||
const playerList = Array.from(players.values())
|
||||
const existingNames = playerList.map((p) => p.name)
|
||||
const emoji = playerData?.emoji ?? '🎮'
|
||||
|
||||
const newPlayer = {
|
||||
name: playerData?.name ?? generateUniquePlayerName(existingNames),
|
||||
emoji: playerData?.emoji ?? '🎮',
|
||||
name: playerData?.name ?? generateUniquePlayerName(existingNames, emoji),
|
||||
emoji,
|
||||
color: playerData?.color ?? getNextPlayerColor(playerList),
|
||||
isActive: playerData?.isActive ?? false,
|
||||
}
|
||||
@@ -254,8 +260,13 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
deletePlayer(player.id)
|
||||
})
|
||||
|
||||
// Generate unique names for default players
|
||||
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
|
||||
// Generate unique names for default players, themed by their emoji
|
||||
const existingNames: string[] = []
|
||||
const generatedNames = DEFAULT_PLAYER_CONFIGS.map((config) => {
|
||||
const name = generateUniquePlayerName(existingNames, config.emoji)
|
||||
existingNames.push(name)
|
||||
return name
|
||||
})
|
||||
|
||||
// Create default players with generated names
|
||||
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
|
||||
|
||||
@@ -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
|
||||
@@ -353,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: {
|
||||
@@ -367,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: {
|
||||
@@ -391,7 +389,6 @@ export function useRoomData() {
|
||||
createdAt: Date
|
||||
}
|
||||
}) => {
|
||||
console.log('[useRoomData] New report submitted:', data)
|
||||
setModerationEvent({
|
||||
type: 'report',
|
||||
data: {
|
||||
@@ -416,7 +413,6 @@ export function useRoomData() {
|
||||
createdAt: Date
|
||||
}
|
||||
}) => {
|
||||
console.log('[useRoomData] Room invitation received:', data)
|
||||
setModerationEvent({
|
||||
type: 'invitation',
|
||||
data: {
|
||||
@@ -439,7 +435,6 @@ export function useRoomData() {
|
||||
createdAt: Date
|
||||
}
|
||||
}) => {
|
||||
console.log('[useRoomData] New join request submitted:', data)
|
||||
setModerationEvent({
|
||||
type: 'join-request',
|
||||
data: {
|
||||
@@ -454,7 +449,7 @@ export function useRoomData() {
|
||||
const handleRoomGameChanged = (data: {
|
||||
roomId: string
|
||||
gameName: string | null
|
||||
gameConfig: Record<string, unknown>
|
||||
gameConfig?: Record<string, unknown>
|
||||
}) => {
|
||||
console.log('[useRoomData] Room game changed:', data)
|
||||
if (data.roomId === roomData?.id) {
|
||||
@@ -463,6 +458,8 @@ export function useRoomData() {
|
||||
return {
|
||||
...prev,
|
||||
gameName: data.gameName,
|
||||
// Only update gameConfig if it was provided in the broadcast
|
||||
...(data.gameConfig !== undefined ? { gameConfig: data.gameConfig } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -496,7 +493,6 @@ export function useRoomData() {
|
||||
// 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])
|
||||
@@ -591,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) {
|
||||
@@ -631,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`, {
|
||||
@@ -638,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!
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -678,6 +683,18 @@ 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' },
|
||||
@@ -688,8 +705,11 @@ async function updateGameConfigApi(params: {
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -710,7 +730,10 @@ export function useUpdateGameConfig() {
|
||||
gameConfig: variables.gameConfig,
|
||||
}
|
||||
})
|
||||
console.log('[useUpdateGameConfig] Updated cache with new gameConfig:', variables.gameConfig)
|
||||
console.log(
|
||||
'[useUpdateGameConfig] Updated cache with new gameConfig:',
|
||||
JSON.stringify(variables.gameConfig, null, 2)
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -180,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,
|
||||
@@ -401,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: [],
|
||||
@@ -422,7 +413,7 @@ export class MemoryQuizGameValidator
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
playerScores: {},
|
||||
playMode: 'cooperative',
|
||||
playMode: config.playMode || 'cooperative',
|
||||
numberFoundBy: {},
|
||||
// UI state
|
||||
gamePhase: 'setup',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('playerNames', () => {
|
||||
it('should append number if all combinations are exhausted', () => {
|
||||
// Create a mock with limited attempts
|
||||
const existingNames = ['Swift Ninja']
|
||||
const name = generateUniquePlayerName(existingNames, 1)
|
||||
const name = generateUniquePlayerName(existingNames, undefined, 1)
|
||||
|
||||
// Should either be unique or have a number appended
|
||||
expect(name).toBeTruthy()
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* Fun automatic player name generation system
|
||||
* Generates creative names by combining adjectives with nouns/roles
|
||||
*
|
||||
* Supports avatar-specific theming! Each emoji can have its own personality-matched words.
|
||||
* Falls back gracefully: emoji-specific → category → generic abacus theme
|
||||
*/
|
||||
|
||||
import { EMOJI_SPECIFIC_WORDS, EMOJI_TO_THEME, THEMED_WORD_LISTS } from './themedWords'
|
||||
|
||||
// Generic abacus-themed words (used as ultimate fallback)
|
||||
const ADJECTIVES = [
|
||||
// Abacus-themed adjectives
|
||||
'Ancient',
|
||||
@@ -114,32 +120,96 @@ const NOUNS = [
|
||||
]
|
||||
|
||||
/**
|
||||
* Generate a random player name by combining an adjective and noun
|
||||
* Select a word list tier using weighted random selection
|
||||
* Balanced mix: emoji-specific (50%), category (25%), global abacus (25%)
|
||||
*/
|
||||
export function generatePlayerName(): string {
|
||||
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
|
||||
function selectWordListTier(emoji: string, wordType: 'adjectives' | 'nouns'): string[] {
|
||||
// Collect available tiers
|
||||
const availableTiers: Array<{ weight: number; words: string[] }> = []
|
||||
|
||||
// Emoji-specific tier (50% preference)
|
||||
const emojiSpecific = EMOJI_SPECIFIC_WORDS[emoji]
|
||||
if (emojiSpecific) {
|
||||
availableTiers.push({ weight: 50, words: emojiSpecific[wordType] })
|
||||
}
|
||||
|
||||
// Category tier (25% preference)
|
||||
const category = EMOJI_TO_THEME[emoji]
|
||||
if (category) {
|
||||
const categoryTheme = THEMED_WORD_LISTS[category]
|
||||
if (categoryTheme) {
|
||||
availableTiers.push({ weight: 25, words: categoryTheme[wordType] })
|
||||
}
|
||||
}
|
||||
|
||||
// Global abacus tier (25% preference)
|
||||
availableTiers.push({ weight: 25, words: wordType === 'adjectives' ? ADJECTIVES : NOUNS })
|
||||
|
||||
// Weighted random selection
|
||||
const totalWeight = availableTiers.reduce((sum, tier) => sum + tier.weight, 0)
|
||||
let random = Math.random() * totalWeight
|
||||
|
||||
for (const tier of availableTiers) {
|
||||
random -= tier.weight
|
||||
if (random <= 0) {
|
||||
return tier.words
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback (should never reach here)
|
||||
return wordType === 'adjectives' ? ADJECTIVES : NOUNS
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random player name by combining an adjective and noun
|
||||
* Optionally themed based on avatar emoji for ultra-personalized names!
|
||||
* Uses per-word-type probabilistic tier selection for natural variety
|
||||
*
|
||||
* @param emoji - Optional emoji avatar to theme the name around
|
||||
* @returns A creative player name like "Grinning Calculator" or "Lightning Smiler"
|
||||
*/
|
||||
export function generatePlayerName(emoji?: string): string {
|
||||
if (!emoji) {
|
||||
// No emoji provided, use pure abacus theme
|
||||
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
|
||||
return `${adjective} ${noun}`
|
||||
}
|
||||
|
||||
// Select tier independently for each word type
|
||||
// This creates natural mixing: adjective might be emoji-specific while noun is global
|
||||
const adjectiveList = selectWordListTier(emoji, 'adjectives')
|
||||
const nounList = selectWordListTier(emoji, 'nouns')
|
||||
|
||||
const adjective = adjectiveList[Math.floor(Math.random() * adjectiveList.length)]
|
||||
const noun = nounList[Math.floor(Math.random() * nounList.length)]
|
||||
|
||||
return `${adjective} ${noun}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique player name that doesn't conflict with existing players
|
||||
* @param existingNames - Array of names already in use
|
||||
* @param emoji - Optional emoji avatar to theme the name around
|
||||
* @param maxAttempts - Maximum attempts to find a unique name (default: 50)
|
||||
* @returns A unique player name
|
||||
*/
|
||||
export function generateUniquePlayerName(existingNames: string[], maxAttempts = 50): string {
|
||||
export function generateUniquePlayerName(
|
||||
existingNames: string[],
|
||||
emoji?: string,
|
||||
maxAttempts = 50
|
||||
): string {
|
||||
const existingNamesSet = new Set(existingNames.map((name) => name.toLowerCase()))
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const name = generatePlayerName()
|
||||
const name = generatePlayerName(emoji)
|
||||
if (!existingNamesSet.has(name.toLowerCase())) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if we can't find a unique name, append a number
|
||||
const baseName = generatePlayerName()
|
||||
const baseName = generatePlayerName(emoji)
|
||||
let counter = 1
|
||||
while (existingNamesSet.has(`${baseName} ${counter}`.toLowerCase())) {
|
||||
counter++
|
||||
@@ -150,12 +220,13 @@ export function generateUniquePlayerName(existingNames: string[], maxAttempts =
|
||||
/**
|
||||
* Generate a batch of unique player names
|
||||
* @param count - Number of names to generate
|
||||
* @param emoji - Optional emoji avatar to theme the names around
|
||||
* @returns Array of unique player names
|
||||
*/
|
||||
export function generateUniquePlayerNames(count: number): string[] {
|
||||
export function generateUniquePlayerNames(count: number, emoji?: string): string[] {
|
||||
const names: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const name = generateUniquePlayerName(names)
|
||||
const name = generateUniquePlayerName(names, emoji)
|
||||
names.push(name)
|
||||
}
|
||||
return names
|
||||
|
||||
6664
apps/web/src/utils/themedWords.ts
Normal file
6664
apps/web/src/utils/themedWords.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.17.2",
|
||||
"version": "3.20.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user