Compare commits

...

7 Commits

Author SHA1 Message Date
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
16 changed files with 1189 additions and 88 deletions

View File

@@ -1,3 +1,29 @@
## [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,237 @@
# Game Settings Persistence Architecture
## Overview
Game settings in room mode persist across game switches using a nested gameConfig structure. This document describes the architecture and common pitfalls.
## Data Structure
Settings are stored in the `arcadeRooms` table's `gameConfig` column with this structure:
```typescript
{
"matching": {
"gameType": "complement-pairs",
"difficulty": 15,
"turnTimer": 60
},
"memory-quiz": {
"selectedCount": 8,
"displayTime": 3.0,
"selectedDifficulty": "medium",
"playMode": "competitive"
}
}
```
**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
## Critical Components
Settings persistence requires coordination between THREE systems:
### 1. Client-Side Provider
**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`
**Example:** `RoomMemoryQuizProvider.tsx:211-247`
```typescript
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])
```
### 2. Socket Server (Session Creation)
**Location:** `src/socket-server.ts:86-125`
**Responsibilities:**
- Create initial arcade session when user joins room
- Read saved settings from `room.gameConfig[gameName]`
- Pass settings to validator's `getInitialState()`
**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',
})
```
### 3. Game Validator
**Location:** `src/lib/arcade/validation/{Game}Validator.ts`
**Responsibilities:**
- Define `getInitialState()` method that creates initial game state
- Accept ALL game settings as parameters
- Use provided values or fall back to defaults
**Example:** `MemoryQuizGameValidator.ts:404-442`
```typescript
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!
}
}
```
## Common Bugs and Solutions
### Bug #1: Settings Wiped When Returning to Game Selection
**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
```typescript
// ❌ WRONG
body: JSON.stringify({ gameName: null, gameConfig: null })
// ✅ CORRECT
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]`
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()
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
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
## 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
## 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.
## Refactoring Recommendations
See next section for suggested improvements to prevent these bugs.

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,39 @@
-- 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` (
`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 `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`)
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;

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,197 @@
/**
* 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, ...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> {
await setGameConfig(roomId, gameName, { [field]: value } as Partial<GameConfigByName[T]>)
}
/**
* 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,79 @@
/**
* 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'
/**
* Configuration for matching (memory pairs) game
*/
export interface MatchingGameConfig {
gameType: 'abacus-numeral' | 'complement-pairs'
difficulty: number
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.17.13",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [