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

14 KiB

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

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

// 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

// 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:

// 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:

// 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

// 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

// 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

// 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:

// 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:

{
  "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

Create room_game_configs table with one row per game per room:

// 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

-- 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