Compare commits

...

13 Commits

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

### Features

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

### Documentation

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

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

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

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

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

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

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

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

### Bug Fixes

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

### Documentation

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

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

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

All TypeScript errors resolved. Compilation passes cleanly.

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

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

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

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

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

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

### Code Refactoring

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

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

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

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

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

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

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

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

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

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

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

### Code Refactoring

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

### Documentation

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

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

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

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

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

This will help identify any remaining persistence issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:50:25 -05:00
18 changed files with 1522 additions and 88 deletions

View File

@@ -1,3 +1,53 @@
## [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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,8 @@ import { recordRoomMemberHistory } from '@/lib/arcade/room-member-history'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
import { getAllGameConfigs, setGameConfig } from '@/lib/arcade/game-config-helpers'
import type { GameName } from '@/lib/arcade/validation'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -122,9 +124,16 @@ 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(
@@ -146,12 +155,15 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
.where(eq(schema.arcadeRooms.id, roomId))
.returning()
// Get aggregated game configs from new table
const gameConfig = await getAllGameConfigs(roomId)
console.log(
'[Settings API] Room state in database AFTER update:',
JSON.stringify(
{
gameName: updatedRoom.gameName,
gameConfig: updatedRoom.gameConfig,
gameConfig,
},
null,
2
@@ -171,11 +183,7 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
} = {
roomId,
gameName: body.gameName,
}
// Only include gameConfig if it was explicitly provided
if (body.gameConfig !== undefined) {
broadcastData.gameConfig = body.gameConfig
gameConfig, // Include aggregated configs from new table
}
io.to(`room:${roomId}`).emit('room-game-changed', broadcastData)
@@ -251,7 +259,15 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
}
}
return NextResponse.json({ room: updatedRoom }, { status: 200 })
return NextResponse.json(
{
room: {
...updatedRoom,
gameConfig, // Include aggregated configs from new table
},
},
{ status: 200 }
)
} catch (error: any) {
console.error('Failed to update room settings:', error)
return NextResponse.json({ error: 'Failed to update room settings' }, { status: 500 })

View File

@@ -4,6 +4,7 @@ import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import { getAllGameConfigs } from '@/lib/arcade/game-config-helpers'
/**
* GET /api/arcade/rooms/current
@@ -28,13 +29,16 @@ 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: room.gameConfig,
gameConfig,
},
null,
2
@@ -54,7 +58,10 @@ export async function GET() {
}
return NextResponse.json({
room,
room: {
...room,
gameConfig, // Override with configs from new table
},
members,
memberPlayers: memberPlayersObj,
})

View File

@@ -210,23 +210,19 @@ 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
console.log('[RoomMemoryQuizProvider] Initializing - gameConfig:', gameConfig)
if (!gameConfig) {
console.log('[RoomMemoryQuizProvider] No gameConfig, using initialState')
return initialState
}
// Get settings for this specific game (memory-quiz)
const savedConfig = gameConfig['memory-quiz'] as Record<string, any> | null | undefined
console.log('[RoomMemoryQuizProvider] Loading saved config for memory-quiz:', savedConfig)
if (!savedConfig) {
console.log('[RoomMemoryQuizProvider] No saved config for memory-quiz, using initialState')
return initialState
}
const merged = {
return {
...initialState,
// Restore settings from saved config
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
@@ -234,14 +230,6 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig.playMode ?? initialState.playMode,
}
console.log('[RoomMemoryQuizProvider] Merged state:', {
selectedCount: merged.selectedCount,
displayTime: merged.displayTime,
selectedDifficulty: merged.selectedDifficulty,
playMode: merged.playMode,
})
return merged
}, [roomData?.gameConfig])
// Arcade session integration WITH room sync
@@ -399,26 +387,19 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
// Settings are scoped by game name to preserve settings when switching games
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
console.log('[RoomMemoryQuizProvider] Current gameConfig:', currentGameConfig)
const currentMemoryQuizConfig =
(currentGameConfig['memory-quiz'] as Record<string, any>) || {}
console.log('[RoomMemoryQuizProvider] Current memory-quiz config:', currentMemoryQuizConfig)
const updatedConfig = {
...currentGameConfig,
'memory-quiz': {
...currentMemoryQuizConfig,
[field]: value,
},
}
console.log('[RoomMemoryQuizProvider] Saving updated gameConfig:', updatedConfig)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
gameConfig: {
...currentGameConfig,
'memory-quiz': {
...currentMemoryQuizConfig,
[field]: value,
},
},
})
} else {
console.warn('[RoomMemoryQuizProvider] No roomData.id, cannot save config')
}
},
[viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,8 @@
* Validates all game moves and state transitions
*/
import type { DifficultyLevel, SorobanQuizState } from '@/app/arcade/memory-quiz/types'
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
import type {
GameValidator,
MemoryQuizGameMove,
@@ -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',

View File

@@ -15,6 +15,7 @@ import { getRoomMembers, getUserRooms, setMemberOnline } from './lib/arcade/room
import { getRoomActivePlayers, getRoomPlayerIds } from './lib/arcade/player-manager'
import type { GameMove, GameName } from './lib/arcade/validation'
import { getValidator } from './lib/arcade/validation'
import { getGameConfig } from './lib/arcade/game-config-helpers'
// Use globalThis to store socket.io instance to avoid module isolation issues
// This ensures the same instance is accessible across dynamic imports
@@ -81,29 +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') {
// Access nested gameConfig: { matching: { gameType, difficulty, turnTimer } }
const matchingConfig = (room.gameConfig as any)?.matching || {}
initialState = validator.getInitialState({
difficulty: matchingConfig.difficulty || 6,
gameType: matchingConfig.gameType || 'abacus-numeral',
turnTimer: matchingConfig.turnTimer || 30,
})
} else if (room.gameName === 'memory-quiz') {
// Access nested gameConfig: { 'memory-quiz': { selectedCount, displayTime, selectedDifficulty, playMode } }
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',
})
} else {
// Fallback for other games
initialState = validator.getInitialState(room.gameConfig || {})
}
// Get game-specific config from database (type-safe)
const gameConfig = await getGameConfig(roomId, room.gameName as GameName)
const initialState = validator.getInitialState(gameConfig)
session = await createArcadeSession({
userId,

View File

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