Compare commits

...

2 Commits

Author SHA1 Message Date
semantic-release-bot
61196ccbff chore(release): 3.22.2 [skip ci]
## [3.22.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.1...v3.22.2) (2025-10-16)

### Code Refactoring

* **arcade:** create unified validator registry to fix dual registration ([f775fc5](f775fc55e5)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
2025-10-16 01:39:05 +00:00
Thomas Hallock
f775fc55e5 refactor(arcade): create unified validator registry to fix dual registration
**Problem**: Games required dual registration - once in client registry
(game-registry.ts) and again in server validator map (validation/index.ts).
This broke the modular architecture goal.

**Solution**: Created unified isomorphic validator registry that serves
both client and server needs.

## Changes

### New File
- `src/lib/arcade/validators.ts` - Unified validator registry
  - Single source of truth for all game validators
  - Auto-derives GameName type from registry keys
  - Isomorphic (runs on both client and server)

### Updated Files
- `validation/index.ts` - Converted to re-export from unified registry
  - Maintains backwards compatibility
  - Marked as deprecated
- `validation/types.ts` - GameName now re-exported from validators
  - No longer hard-coded union type
  - Auto-updates when new games added
- `game-registry.ts` - Added runtime validation
  - Checks if validator registered server-side
  - Warns on registration mismatch
- `session-manager.ts` - Import from unified registry
- `socket-server.ts` - Import from unified registry
- `route.ts` (rooms API) - Use hasValidator() instead of hard-coded array
- `game-config-helpers.ts` - Handle ExtendedGameName for legacy games
  - Supports both registered validators and legacy 'complement-race'
  - TODO comment for migration

## Benefits
 Single registration point for new games
 Auto-derived GameName type (no manual updates)
 Type-safe validator access
 Backwards compatible with existing code
 Clear migration path for old games

## Migration Status
-  number-guesser: Uses new system
- 🔄 matching, memory-quiz: Use new validator registry, not migrated to arcade-games/ yet
-  complement-race: Legacy game, handled via ExtendedGameName

Addresses critical Issue #1 from AUDIT_MODULAR_GAME_SYSTEM.md

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 20:38:15 -05:00
10 changed files with 138 additions and 41 deletions

View File

@@ -1,3 +1,10 @@
## [3.22.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.1...v3.22.2) (2025-10-16)
### Code Refactoring
* **arcade:** create unified validator registry to fix dual registration ([f775fc5](https://github.com/antialias/soroban-abacus-flashcards/commit/f775fc55e50af0c3a29b3e00fc722e7d7ce90212)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
## [3.22.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.0...v3.22.1) (2025-10-16)

View File

@@ -3,7 +3,7 @@ import { createRoom, listActiveRooms } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers, isMember } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import type { GameName } from '@/lib/arcade/validation'
import { hasValidator, type GameName } from '@/lib/arcade/validators'
/**
* GET /api/arcade/rooms
@@ -72,8 +72,7 @@ export async function POST(req: NextRequest) {
// Validate game name if provided (gameName is now optional)
if (body.gameName) {
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
if (!validGames.includes(body.gameName)) {
if (!hasValidator(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
}
}

View File

@@ -8,7 +8,7 @@
import { and, eq } from 'drizzle-orm'
import { createId } from '@paralleldrive/cuid2'
import { db, schema } from '@/db'
import type { GameName } from './validation'
import type { GameName } from './validators'
import type { GameConfigByName } from './game-configs'
import {
DEFAULT_MATCHING_CONFIG,
@@ -17,10 +17,16 @@ import {
DEFAULT_NUMBER_GUESSER_CONFIG,
} from './game-configs'
/**
* Extended game name type that includes both registered validators and legacy games
* TODO: Remove 'complement-race' once migrated to the new modular system
*/
type ExtendedGameName = GameName | 'complement-race'
/**
* Get default config for a game
*/
function getDefaultGameConfig(gameName: GameName): GameConfigByName[GameName] {
function getDefaultGameConfig(gameName: ExtendedGameName): GameConfigByName[ExtendedGameName] {
switch (gameName) {
case 'matching':
return DEFAULT_MATCHING_CONFIG
@@ -39,7 +45,7 @@ function getDefaultGameConfig(gameName: GameName): GameConfigByName[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>(
export async function getGameConfig<T extends ExtendedGameName>(
roomId: string,
gameName: T
): Promise<GameConfigByName[T]> {
@@ -65,7 +71,7 @@ export async function getGameConfig<T extends GameName>(
* 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>(
export async function setGameConfig<T extends ExtendedGameName>(
roomId: string,
gameName: T,
config: Partial<GameConfigByName[T]>
@@ -113,7 +119,7 @@ export async function setGameConfig<T extends GameName>(
* Convenience wrapper around setGameConfig
*/
export async function updateGameConfigField<
T extends GameName,
T extends ExtendedGameName,
K extends keyof GameConfigByName[T],
>(roomId: string, gameName: T, field: K, value: GameConfigByName[T][K]): Promise<void> {
// Create a partial config with just the field being updated
@@ -126,7 +132,7 @@ export async function updateGameConfigField<
* 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> {
export async function deleteGameConfig(roomId: string, gameName: ExtendedGameName): Promise<void> {
await db
.delete(schema.roomGameConfigs)
.where(
@@ -142,14 +148,14 @@ export async function deleteGameConfig(roomId: string, gameName: GameName): Prom
*/
export async function getAllGameConfigs(
roomId: string
): Promise<Partial<Record<GameName, unknown>>> {
): Promise<Partial<Record<ExtendedGameName, unknown>>> {
const configs = await db.query.roomGameConfigs.findMany({
where: eq(schema.roomGameConfigs.roomId, roomId),
})
const result: Partial<Record<GameName, unknown>> = {}
const result: Partial<Record<ExtendedGameName, unknown>> = {}
for (const config of configs) {
result[config.gameName as GameName] = config.config
result[config.gameName as ExtendedGameName] = config.config
}
return result
@@ -168,7 +174,7 @@ export async function deleteAllGameConfigs(roomId: string): Promise<void> {
* 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 {
export function validateGameConfig(gameName: ExtendedGameName, config: any): boolean {
switch (gameName) {
case 'matching':
return (

View File

@@ -31,6 +31,28 @@ export function registerGame<
throw new Error(`Game "${name}" is already registered`)
}
// Verify validator is also registered server-side
try {
const { hasValidator, getValidator } = require('./validators')
if (!hasValidator(name)) {
console.error(
`⚠️ Game "${name}" registered but validator not found in server registry!` +
`\n Add to src/lib/arcade/validators.ts to enable multiplayer.`
)
} else {
const serverValidator = getValidator(name)
if (serverValidator !== game.validator) {
console.warn(
`⚠️ Game "${name}" has different validator instances (client vs server).` +
`\n This may cause issues. Ensure both use the same import.`
)
}
}
} catch (error) {
// If validators.ts can't be imported (e.g., in browser), skip check
// This is expected - validator registry is isomorphic but check only runs server-side
}
registry.set(name, game)
console.log(`✅ Registered game: ${name}`)
}

View File

@@ -6,7 +6,8 @@
import { eq } from 'drizzle-orm'
import { db, schema } from '@/db'
import { buildPlayerOwnershipMap, type PlayerOwnershipMap } from './player-ownership'
import { type GameMove, type GameName, getValidator } from './validation'
import { getValidator, type GameName } from './validators'
import type { GameMove } from './validation/types'
export interface CreateSessionOptions {
userId: string // User who owns/created the session (typically room creator)

View File

@@ -1,29 +1,19 @@
/**
* Game validator registry
* Maps game names to their validators
* @deprecated This file now re-exports from the unified registry
* New code should import from '@/lib/arcade/validators' instead
*/
import { matchingGameValidator } from './MatchingGameValidator'
import { memoryQuizGameValidator } from './MemoryQuizGameValidator'
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
import type { GameName, GameValidator } from './types'
// Re-export everything from unified registry
export {
getValidator,
hasValidator,
getRegisteredGameNames,
validatorRegistry,
matchingGameValidator,
memoryQuizGameValidator,
numberGuesserValidator,
} from '../validators'
const validators = new Map<GameName, GameValidator>([
['matching', matchingGameValidator],
['memory-quiz', memoryQuizGameValidator],
['number-guesser', numberGuesserValidator],
// Add other game validators here as they're implemented
])
export function getValidator(gameName: GameName): GameValidator {
const validator = validators.get(gameName)
if (!validator) {
throw new Error(`No validator found for game: ${gameName}`)
}
return validator
}
export { matchingGameValidator } from './MatchingGameValidator'
export { memoryQuizGameValidator } from './MemoryQuizGameValidator'
export { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
export type { GameName } from '../validators'
export * from './types'

View File

@@ -6,7 +6,11 @@
import type { MemoryPairsState } from '@/app/games/matching/context/types'
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
export type GameName = 'matching' | 'memory-quiz' | 'complement-race' | 'number-guesser'
/**
* Game name type - auto-derived from validator registry
* @deprecated Import from '@/lib/arcade/validators' instead
*/
export type { GameName } from '../validators'
export interface ValidationResult {
valid: boolean

View File

@@ -0,0 +1,68 @@
/**
* Unified Validator Registry (Isomorphic - runs on client AND server)
*
* This is the single source of truth for game validators.
* Both client and server import validators from here.
*
* To add a new game:
* 1. Import the validator
* 2. Add to validatorRegistry Map
* 3. GameName type will auto-update
*/
import { matchingGameValidator } from './validation/MatchingGameValidator'
import { memoryQuizGameValidator } from './validation/MemoryQuizGameValidator'
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
import type { GameValidator } from './validation/types'
/**
* Central registry of all game validators
* Key: game name (matches manifest.name)
* Value: validator instance
*/
export const validatorRegistry = {
matching: matchingGameValidator,
'memory-quiz': memoryQuizGameValidator,
'number-guesser': numberGuesserValidator,
// Add new games here - GameName type will auto-update
} as const
/**
* Auto-derived game name type from registry
* No need to manually update this!
*/
export type GameName = keyof typeof validatorRegistry
/**
* Get validator for a game
* @throws Error if game not found (fail fast)
*/
export function getValidator(gameName: string): GameValidator {
const validator = validatorRegistry[gameName as GameName]
if (!validator) {
throw new Error(
`No validator found for game: ${gameName}. ` +
`Available games: ${Object.keys(validatorRegistry).join(', ')}`
)
}
return validator
}
/**
* Check if a game has a registered validator
*/
export function hasValidator(gameName: string): gameName is GameName {
return gameName in validatorRegistry
}
/**
* Get all registered game names
*/
export function getRegisteredGameNames(): GameName[] {
return Object.keys(validatorRegistry) as GameName[]
}
/**
* Re-export validators for backwards compatibility
*/
export { matchingGameValidator, memoryQuizGameValidator, numberGuesserValidator }

View File

@@ -13,8 +13,8 @@ import {
import { createRoom, getRoomById } from './lib/arcade/room-manager'
import { getRoomMembers, getUserRooms, setMemberOnline } from './lib/arcade/room-membership'
import { getRoomActivePlayers, getRoomPlayerIds } from './lib/arcade/player-manager'
import type { GameMove, GameName } from './lib/arcade/validation'
import { getValidator } from './lib/arcade/validation'
import { getValidator, type GameName } from './lib/arcade/validators'
import type { GameMove } from './lib/arcade/validation/types'
import { getGameConfig } from './lib/arcade/game-config-helpers'
// Use globalThis to store socket.io instance to avoid module isolation issues

View File

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