Compare commits

...

8 Commits

Author SHA1 Message Date
semantic-release-bot
bf02bc14fd chore(release): 3.18.1 [skip ci]
## [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](ffb626f403))
2025-10-15 18:55:55 +00:00
Thomas Hallock
ffb626f403 fix(arcade): prevent empty update in settings API when only gameConfig changes
When only gameConfig is updated (without accessMode, password, or gameName),
the updateData object remained empty, causing Drizzle to throw "No values to set"
error when attempting to update arcade_rooms table.

Now only updates arcade_rooms if there are actual fields to update, preventing
the 500 error while still allowing gameConfig-only updates to room_game_configs table.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:54:47 -05:00
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
9 changed files with 536 additions and 193 deletions

View File

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

View File

@@ -2,115 +2,279 @@
## Overview
Game settings in room mode persist across game switches using a nested gameConfig structure. This document describes the architecture and common pitfalls.
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.
## Data Structure
## Database Schema
Settings are stored in the `arcadeRooms` table's `gameConfig` column with this structure:
Settings are stored in the `room_game_configs` table:
```typescript
```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
{
"matching": {
"gameType": "complement-pairs",
"difficulty": 15,
"turnTimer": 60
},
"memory-quiz": {
"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)
}
}
```
**Key Points:**
- Settings are **nested by game name** (`matching`, `memory-quiz`, etc.)
- Each game has its own isolated settings object
- This allows switching games without losing settings
### 3. Socket Server (Session Creation)
**Location:** `src/socket-server.ts:70-90`
## Critical Components
**Responsibilities:**
- Create initial arcade session when user joins room
- Read saved settings using `getGameConfig()` helper
- Pass settings to validator's `getInitialState()`
Settings persistence requires coordination between THREE systems:
**Example:**
```typescript
const room = await getRoomById(roomId)
const validator = getValidator(room.gameName as GameName)
### 1. Client-Side Provider
// 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:**
- Load saved settings from `roomData.gameConfig[gameName]`
- Merge saved settings with `initialState` defaults
- Save settings changes to database via `updateGameConfig`
- Read settings from `roomData.gameConfig[gameName]`
- Merge with `initialState` defaults
- Works transparently with new backend structure
**Example:** `RoomMemoryQuizProvider.tsx:211-247`
**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,
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
displayTime: savedConfig.displayTime ?? initialState.displayTime,
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig.playMode ?? initialState.playMode,
}
}, [roomData?.gameConfig])
```
### 2. Socket Server (Session Creation)
**Location:** `src/socket-server.ts:86-125`
## Common Bugs and Solutions
**Responsibilities:**
- Create initial arcade session when user joins room
- Read saved settings from `room.gameConfig[gameName]`
- Pass settings to validator's `getInitialState()`
### Bug #1: Settings Not Persisting
**Symptom:** Settings reset to defaults after game switch
**Example:** `socket-server.ts:94-110`
```typescript
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',
})
**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)
```
### 3. Game Validator
**Location:** `src/lib/arcade/validation/{Game}Validator.ts`
### Bug #2: TypeScript Errors About Missing Fields
**Symptom:** `Property '{field}' is missing in type ...`
**Responsibilities:**
- Define `getInitialState()` method that creates initial game state
- Accept ALL game settings as parameters
- Use provided values or fall back to defaults
**Root Cause:** Validator's `getInitialState()` signature doesn't match shared config type
**Example:** `MemoryQuizGameValidator.ts:404-442`
**Solution:** Import and use the shared config type:
```typescript
// ❌ WRONG
getInitialState(config: {
selectedCount: number
displayTime: number
selectedDifficulty: DifficultyLevel
playMode?: 'cooperative' | 'competitive' // ← Must include ALL settings!
}): SorobanQuizState {
return {
// ...
selectedCount: config.selectedCount,
displayTime: config.displayTime,
selectedDifficulty: config.selectedDifficulty,
playMode: config.playMode || 'cooperative', // ← Use config value!
}
}
// Missing playMode!
}): SorobanQuizState
// ✅ CORRECT
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState
```
## Common Bugs and Solutions
### Bug #3: Settings Wiped When Returning to Game Selection
**Symptom:** Settings reset when going back to game selection
### Bug #1: Settings Wiped When Returning to Game Selection
**Root Cause:** Sending `gameConfig: null` in PATCH request
**Symptom:** Settings reset to defaults after going back to game selection
**Root Cause:** `clearRoomGameApi` was sending `gameConfig: null`
**Solution:** Only send `gameName: null`, don't send `gameConfig` at all
**Solution:** Only send `gameName: null`, don't touch gameConfig:
```typescript
// ❌ WRONG
body: JSON.stringify({ gameName: null, gameConfig: null })
@@ -119,106 +283,76 @@ body: JSON.stringify({ gameName: null, gameConfig: null })
body: JSON.stringify({ gameName: null })
```
**Fixed in:** `useRoomData.ts:638-654`
### Bug #2: Settings Not Loaded from Database
**Symptom:** Session always uses default settings, ignoring saved values
**Root Cause:** Socket server reading from wrong level of nesting
**Example Problem:**
```typescript
// ❌ WRONG - reads from root level
const gameType = room.gameConfig.gameType // undefined!
// ✅ CORRECT - reads from nested game config
const matchingConfig = room.gameConfig.matching
const gameType = matchingConfig.gameType
```
**Fixed in:** `socket-server.ts:86-125`
### Bug #3: Validator Ignores Setting
**Symptom:** Specific setting always resets to default (e.g., playMode always "cooperative")
**Root Cause:** Validator's `getInitialState()` config parameter missing the setting
**How to Diagnose:**
1. Check validator's `getInitialState()` TypeScript signature
2. Ensure ALL game settings are included as parameters
3. Ensure settings use `config.{setting}` not hardcoded values
**Example Problem:**
```typescript
// ❌ WRONG - playMode not in config type
getInitialState(config: {
selectedCount: number
displayTime: number
selectedDifficulty: DifficultyLevel
}): SorobanQuizState {
return {
playMode: 'cooperative', // ← Hardcoded!
}
}
// ✅ CORRECT - playMode in config type and used
getInitialState(config: {
selectedCount: number
displayTime: number
selectedDifficulty: DifficultyLevel
playMode?: 'cooperative' | 'competitive'
}): SorobanQuizState {
return {
playMode: config.playMode || 'cooperative', // ← Uses config!
}
}
```
**Fixed in:** `MemoryQuizGameValidator.ts:404-442`
## Debugging Checklist
When a setting doesn't persist:
1. **Check database:**
- Add logging in `/api/arcade/rooms/[roomId]/settings/route.ts`
- Verify setting is saved to `gameConfig[gameName]`
- Query `room_game_configs` table
- Verify row exists for room + game
- Verify JSON config has correct structure
2. **Check socket server:**
- Add logging in `socket-server.ts` join-arcade-session handler
- Verify `room.gameConfig[gameName].{setting}` is read correctly
- Verify setting is passed to `validator.getInitialState()`
- Verify `initialState.{setting}` has correct value after getInitialState()
2. **Check API write path:**
- `/api/arcade/rooms/[roomId]/settings` logs
- Verify `setGameConfig()` is called
- Check for errors in console
3. **Check validator:**
- Verify `getInitialState()` config parameter includes the setting
- Verify setting uses `config.{setting}` not a hardcoded value
- Add logging to see what config is received and what state is returned
3. **Check API read path:**
- `/api/arcade/rooms/current` logs
- Verify `getAllGameConfigs()` returns data
- Check `room.gameConfig` in response
4. **Check client provider:**
- Add logging in `Room{Game}Provider.tsx` mergedInitialState useMemo
- Verify `roomData.gameConfig[gameName].{setting}` is read correctly
- Verify merged state includes the saved value
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 game's state type** (`types.ts`)
2. **Update the Provider** (`Room{Game}Provider.tsx`):
- Add to `mergedInitialState` useMemo
- Add to `setConfig` function
- Add to `updateGameConfig` call
3. **Update the Validator** (`{Game}Validator.ts`):
- Add to `getInitialState()` config parameter type
- Add to returned state object, using `config.{setting}`
- Add validation case in `validateSetConfig()`
4. **Update Socket Server** (`socket-server.ts`):
- Add to `{game}Config` extraction
- Add to `getInitialState()` call
5. **Update the UI component** to expose the setting
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
@@ -228,10 +362,60 @@ Manual test procedure:
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
5. **Verify ALL settings retained their values**
**Expected behavior:** All settings should be exactly as you left them.
## Refactoring Recommendations
## Migration Notes
See next section for suggested improvements to prevent these bugs.
**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,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

@@ -1,6 +1,8 @@
-- Create room_game_configs table for per-game settings storage
-- This replaces the monolithic gameConfig JSON column with a normalized table
CREATE TABLE `room_game_configs` (
-- 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,
@@ -10,30 +12,22 @@ CREATE TABLE `room_game_configs` (
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `room_game_idx` ON `room_game_configs` (`room_id`, `game_name`);
-- Create unique index
CREATE UNIQUE INDEX IF NOT EXISTS `room_game_idx` ON `room_game_configs` (`room_id`,`game_name`);
--> statement-breakpoint
-- Migrate existing 'matching' configs from arcade_rooms.game_config
INSERT INTO `room_game_configs` (`id`, `room_id`, `game_name`, `config`, `created_at`, `updated_at`)
-- 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))),
`id` as room_id,
'matching' as game_name,
json_extract(`game_config`, '$.matching') as config,
`created_at`,
`last_activity` as updated_at
FROM `arcade_rooms`
WHERE json_extract(`game_config`, '$.matching') IS NOT NULL;
--> statement-breakpoint
-- Migrate existing 'memory-quiz' configs from arcade_rooms.game_config
INSERT INTO `room_game_configs` (`id`, `room_id`, `game_name`, `config`, `created_at`, `updated_at`)
SELECT
lower(hex(randomblob(16))),
`id` as room_id,
'memory-quiz' as game_name,
json_extract(`game_config`, '$."memory-quiz"') as config,
`created_at`,
`last_activity` as updated_at
FROM `arcade_rooms`
WHERE json_extract(`game_config`, '$."memory-quiz"') IS NOT NULL;
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

@@ -148,12 +148,15 @@ 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)

View File

@@ -79,7 +79,7 @@ export async function setGameConfig<T extends GameName>(
if (existing) {
// Update existing config (merge with existing values)
const mergedConfig = { ...existing.config, ...config }
const mergedConfig = { ...(existing.config as object), ...config }
await db
.update(schema.roomGameConfigs)
.set({
@@ -113,7 +113,10 @@ export async function updateGameConfigField<
T extends GameName,
K extends keyof GameConfigByName[T],
>(roomId: string, gameName: T, field: K, value: GameConfigByName[T][K]): Promise<void> {
await setGameConfig(roomId, gameName, { [field]: value } as Partial<GameConfigByName[T]>)
// 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)
}
/**

View File

@@ -10,13 +10,14 @@
*/
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: 'abacus-numeral' | 'complement-pairs'
difficulty: number
gameType: GameType
difficulty: Difficulty
turnTimer: number
}

View File

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