soroban-abacus-flashcards/apps/web/.claude/GAME_SETTINGS_PERSISTENCE.md

461 lines
12 KiB
Markdown

# 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