Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61196ccbff | ||
|
|
f775fc55e5 |
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
68
apps/web/src/lib/arcade/validators.ts
Normal file
68
apps/web/src/lib/arcade/validators.ts
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.22.1",
|
||||
"version": "3.22.2",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user