feat(arcade): add modular game SDK and registry system
Create foundation for modular arcade game architecture: **Game SDK** (`/src/lib/arcade/game-sdk/`): - Stable API surface that games can safely import - Type-safe game definition with `defineGame()` helper - Controlled hook exports (useArcadeSession, useRoomData, etc.) - Player ownership and metadata utilities - Error boundary component for game crashes **Manifest System**: - YAML-based game manifests with Zod validation - Game metadata (name, icon, description, difficulty, etc.) - Type-safe manifest loading with `loadManifest()` **Game Registry**: - Central registry for all arcade games - Explicit registration pattern via `registerGame()` - Helper functions to query available games **Type Safety**: - Full TypeScript contracts for games - GameValidator, GameState, GameMove, GameConfig types - Compile-time validation of game implementations This establishes the plugin system for drop-in arcade games. Next: Create demo games to exercise the system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0eed26966c
commit
de30bec479
|
|
@ -59,6 +59,7 @@
|
|||
"drizzle-orm": "^0.44.6",
|
||||
"emojibase-data": "^16.0.3",
|
||||
"jose": "^6.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"make-plural": "^7.4.0",
|
||||
"nanoid": "^5.1.6",
|
||||
|
|
@ -80,6 +81,7 @@
|
|||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Game Registry
|
||||
*
|
||||
* Central registry for all arcade games.
|
||||
* Games are explicitly registered here after being defined.
|
||||
*/
|
||||
|
||||
import type { GameDefinition } from './game-sdk/types'
|
||||
|
||||
/**
|
||||
* Global game registry
|
||||
* Maps game name to game definition
|
||||
*/
|
||||
const registry = new Map<string, GameDefinition>()
|
||||
|
||||
/**
|
||||
* Register a game in the registry
|
||||
*
|
||||
* @param game - Game definition to register
|
||||
* @throws Error if game with same name already registered
|
||||
*/
|
||||
export function registerGame(game: GameDefinition): void {
|
||||
const { name } = game.manifest
|
||||
|
||||
if (registry.has(name)) {
|
||||
throw new Error(`Game "${name}" is already registered`)
|
||||
}
|
||||
|
||||
registry.set(name, game)
|
||||
console.log(`✅ Registered game: ${name}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a game from the registry
|
||||
*
|
||||
* @param gameName - Internal game identifier
|
||||
* @returns Game definition or undefined if not found
|
||||
*/
|
||||
export function getGame(gameName: string): GameDefinition | undefined {
|
||||
return registry.get(gameName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered games
|
||||
*
|
||||
* @returns Array of all game definitions
|
||||
*/
|
||||
export function getAllGames(): GameDefinition[] {
|
||||
return Array.from(registry.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available games (where available: true)
|
||||
*
|
||||
* @returns Array of available game definitions
|
||||
*/
|
||||
export function getAvailableGames(): GameDefinition[] {
|
||||
return getAllGames().filter((game) => game.manifest.available)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a game is registered
|
||||
*
|
||||
* @param gameName - Internal game identifier
|
||||
* @returns true if game is registered
|
||||
*/
|
||||
export function hasGame(gameName: string): boolean {
|
||||
return registry.has(gameName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all games from registry (used for testing)
|
||||
*/
|
||||
export function clearRegistry(): void {
|
||||
registry.clear()
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Error Boundary for Arcade Games
|
||||
*
|
||||
* Catches errors in game components and displays a friendly error message
|
||||
* instead of crashing the entire app.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { Component, type ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
gameName?: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export class GameErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: unknown) {
|
||||
console.error('Game error:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
minHeight: '400px',
|
||||
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
color: '#dc2626',
|
||||
}}
|
||||
>
|
||||
Game Error
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '12px',
|
||||
maxWidth: '500px',
|
||||
}}
|
||||
>
|
||||
{this.props.gameName
|
||||
? `There was an error loading the game "${this.props.gameName}".`
|
||||
: 'There was an error loading the game.'}
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<pre
|
||||
style={{
|
||||
background: '#f9fafb',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginTop: '12px',
|
||||
maxWidth: '600px',
|
||||
overflow: 'auto',
|
||||
textAlign: 'left',
|
||||
fontSize: '12px',
|
||||
color: '#374151',
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
padding: '12px 24px',
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Game definition helper
|
||||
* Provides type-safe game registration
|
||||
*/
|
||||
|
||||
import type {
|
||||
GameComponent,
|
||||
GameConfig,
|
||||
GameDefinition,
|
||||
GameMove,
|
||||
GameProviderComponent,
|
||||
GameState,
|
||||
GameValidator,
|
||||
} from './types'
|
||||
import type { GameManifest } from '../manifest-schema'
|
||||
|
||||
/**
|
||||
* Options for defining a game
|
||||
*/
|
||||
export interface DefineGameOptions<
|
||||
TConfig extends GameConfig,
|
||||
TState extends GameState,
|
||||
TMove extends GameMove,
|
||||
> {
|
||||
/** Game manifest (loaded from game.yaml) */
|
||||
manifest: GameManifest
|
||||
|
||||
/** React provider component */
|
||||
Provider: GameProviderComponent
|
||||
|
||||
/** Main game UI component */
|
||||
GameComponent: GameComponent
|
||||
|
||||
/** Server-side validator */
|
||||
validator: GameValidator<TState, TMove>
|
||||
|
||||
/** Default configuration for the game */
|
||||
defaultConfig: TConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a game with full type safety
|
||||
*
|
||||
* This helper ensures all required parts of a game are provided
|
||||
* and returns a properly typed GameDefinition.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export const myGame = defineGame({
|
||||
* manifest: loadManifest('./game.yaml'),
|
||||
* Provider: MyGameProvider,
|
||||
* GameComponent: MyGameComponent,
|
||||
* validator: myGameValidator,
|
||||
* defaultConfig: {
|
||||
* difficulty: 'easy',
|
||||
* maxTime: 60
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function defineGame<
|
||||
TConfig extends GameConfig,
|
||||
TState extends GameState,
|
||||
TMove extends GameMove,
|
||||
>(options: DefineGameOptions<TConfig, TState, TMove>): GameDefinition<TConfig, TState, TMove> {
|
||||
const { manifest, Provider, GameComponent, validator, defaultConfig } = options
|
||||
|
||||
// Validate that manifest.name matches the game identifier
|
||||
if (!manifest.name) {
|
||||
throw new Error('Game manifest must have a "name" field')
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
Provider,
|
||||
GameComponent,
|
||||
validator,
|
||||
defaultConfig,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Arcade Game SDK - Stable API Surface
|
||||
*
|
||||
* This is the ONLY module that games are allowed to import from.
|
||||
* All game code must use this SDK - no direct imports from /src/
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import {
|
||||
* defineGame,
|
||||
* useArcadeSession,
|
||||
* useRoomData,
|
||||
* type GameDefinition
|
||||
* } from '@/lib/arcade/game-sdk'
|
||||
* ```
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Core Types
|
||||
// ============================================================================
|
||||
export type {
|
||||
GameDefinition,
|
||||
GameProviderComponent,
|
||||
GameComponent,
|
||||
GameValidator,
|
||||
GameConfig,
|
||||
GameState,
|
||||
GameMove,
|
||||
ValidationContext,
|
||||
ValidationResult,
|
||||
TeamMoveSentinel,
|
||||
} from './types'
|
||||
|
||||
export { TEAM_MOVE } from './types'
|
||||
|
||||
export type { GameManifest } from '../manifest-schema'
|
||||
|
||||
// ============================================================================
|
||||
// React Hooks (Controlled API)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Arcade session management hook
|
||||
* Handles state synchronization, move validation, and multiplayer sync
|
||||
*/
|
||||
export { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
|
||||
/**
|
||||
* Room data hook - access current room information
|
||||
*/
|
||||
export { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
|
||||
|
||||
/**
|
||||
* Game mode context - access players and game mode
|
||||
*/
|
||||
export { useGameMode } from '@/contexts/GameModeContext'
|
||||
|
||||
/**
|
||||
* Viewer ID hook - get current user's ID
|
||||
*/
|
||||
export { useViewerId } from '@/hooks/useViewerId'
|
||||
|
||||
// ============================================================================
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Player ownership and metadata utilities
|
||||
*/
|
||||
export {
|
||||
buildPlayerMetadata,
|
||||
buildPlayerOwnershipFromRoomData,
|
||||
} from '@/lib/arcade/player-ownership.client'
|
||||
|
||||
/**
|
||||
* Helper for loading and validating game manifests
|
||||
*/
|
||||
export { loadManifest } from './load-manifest'
|
||||
|
||||
/**
|
||||
* Game definition helper
|
||||
*/
|
||||
export { defineGame } from './define-game'
|
||||
|
||||
// ============================================================================
|
||||
// Re-exports for convenience
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common types from contexts
|
||||
*/
|
||||
export type { Player } from '@/contexts/GameModeContext'
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Manifest loading and validation utilities
|
||||
*/
|
||||
|
||||
import yaml from 'js-yaml'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { validateManifest, type GameManifest } from '../manifest-schema'
|
||||
|
||||
/**
|
||||
* Load and validate a game manifest from a YAML file
|
||||
*
|
||||
* @param manifestPath - Absolute path to game.yaml file
|
||||
* @returns Validated GameManifest object
|
||||
* @throws Error if manifest is invalid or file doesn't exist
|
||||
*/
|
||||
export function loadManifest(manifestPath: string): GameManifest {
|
||||
try {
|
||||
const fileContents = readFileSync(manifestPath, 'utf8')
|
||||
const data = yaml.load(fileContents)
|
||||
return validateManifest(data)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to load manifest from ${manifestPath}: ${error.message}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load manifest from a game directory
|
||||
*
|
||||
* @param gameDir - Absolute path to game directory
|
||||
* @returns Validated GameManifest object
|
||||
*/
|
||||
export function loadManifestFromDir(gameDir: string): GameManifest {
|
||||
const manifestPath = join(gameDir, 'game.yaml')
|
||||
return loadManifest(manifestPath)
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Type definitions for the Arcade Game SDK
|
||||
* These types define the contract that all games must implement
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { GameManifest } from '../manifest-schema'
|
||||
import type {
|
||||
GameMove as BaseGameMove,
|
||||
GameValidator as BaseGameValidator,
|
||||
ValidationContext,
|
||||
ValidationResult,
|
||||
} from '../validation/types'
|
||||
|
||||
/**
|
||||
* Re-export base validation types from arcade system
|
||||
*/
|
||||
export type { GameMove, ValidationContext, ValidationResult } from '../validation/types'
|
||||
export { TEAM_MOVE } from '../validation/types'
|
||||
export type { TeamMoveSentinel } from '../validation/types'
|
||||
|
||||
/**
|
||||
* Generic game configuration
|
||||
* Each game defines its own specific config type
|
||||
*/
|
||||
export type GameConfig = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Generic game state
|
||||
* Each game defines its own specific state type
|
||||
*/
|
||||
export type GameState = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Game validator interface
|
||||
* Games must implement this to validate moves server-side
|
||||
*/
|
||||
export interface GameValidator<TState = GameState, TMove extends BaseGameMove = BaseGameMove>
|
||||
extends BaseGameValidator<TState, TMove> {
|
||||
validateMove(state: TState, move: TMove, context?: ValidationContext): ValidationResult
|
||||
isGameComplete(state: TState): boolean
|
||||
getInitialState(config: unknown): TState
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component interface
|
||||
* Each game provides a React context provider that wraps the game UI
|
||||
*/
|
||||
export type GameProviderComponent = (props: { children: ReactNode }) => JSX.Element
|
||||
|
||||
/**
|
||||
* Main game component interface
|
||||
* The root component that renders the game UI
|
||||
*/
|
||||
export type GameComponent = () => JSX.Element
|
||||
|
||||
/**
|
||||
* Complete game definition
|
||||
* This is what games export after using defineGame()
|
||||
*/
|
||||
export interface GameDefinition<
|
||||
TConfig extends GameConfig = GameConfig,
|
||||
TState extends GameState = GameState,
|
||||
TMove extends BaseGameMove = BaseGameMove,
|
||||
> {
|
||||
/** Parsed and validated manifest */
|
||||
manifest: GameManifest
|
||||
|
||||
/** React provider component */
|
||||
Provider: GameProviderComponent
|
||||
|
||||
/** Main game UI component */
|
||||
GameComponent: GameComponent
|
||||
|
||||
/** Server-side validator */
|
||||
validator: GameValidator<TState, TMove>
|
||||
|
||||
/** Default configuration */
|
||||
defaultConfig: TConfig
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Game manifest schema validation
|
||||
* Validates game.yaml files using Zod
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Schema for game manifest (game.yaml)
|
||||
*/
|
||||
export const GameManifestSchema = z.object({
|
||||
name: z.string().min(1).describe('Internal game identifier (e.g., "matching")'),
|
||||
displayName: z.string().min(1).describe('Display name shown to users'),
|
||||
icon: z.string().min(1).describe('Emoji icon for the game'),
|
||||
description: z.string().min(1).describe('Short description'),
|
||||
longDescription: z.string().min(1).describe('Detailed description'),
|
||||
maxPlayers: z.number().int().min(1).max(10).describe('Maximum number of players'),
|
||||
difficulty: z
|
||||
.enum(['Beginner', 'Intermediate', 'Advanced', 'Expert'])
|
||||
.describe('Difficulty level'),
|
||||
chips: z.array(z.string()).describe('Feature chips displayed on game card'),
|
||||
color: z.string().min(1).describe('Color theme (e.g., "purple")'),
|
||||
gradient: z.string().min(1).describe('CSS gradient for card background'),
|
||||
borderColor: z.string().min(1).describe('Border color (e.g., "purple.200")'),
|
||||
available: z.boolean().describe('Whether game is available to play'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Inferred TypeScript type from schema
|
||||
*/
|
||||
export type GameManifest = z.infer<typeof GameManifestSchema>
|
||||
|
||||
/**
|
||||
* Validate a parsed manifest object
|
||||
*/
|
||||
export function validateManifest(data: unknown): GameManifest {
|
||||
return GameManifestSchema.parse(data)
|
||||
}
|
||||
|
|
@ -152,6 +152,9 @@ importers:
|
|||
jose:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
js-yaml:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
lucide-react:
|
||||
specifier: ^0.294.0
|
||||
version: 0.294.0(react@18.3.1)
|
||||
|
|
@ -210,6 +213,9 @@ importers:
|
|||
'@types/better-sqlite3':
|
||||
specifier: ^7.6.13
|
||||
version: 7.6.13
|
||||
'@types/js-yaml':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.19
|
||||
|
|
@ -3668,6 +3674,9 @@ packages:
|
|||
'@types/istanbul-reports@3.0.4':
|
||||
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
|
||||
|
||||
'@types/js-yaml@4.0.9':
|
||||
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
||||
|
||||
'@types/jsdom@21.1.7':
|
||||
resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==}
|
||||
|
||||
|
|
@ -13150,6 +13159,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/istanbul-lib-report': 3.0.3
|
||||
|
||||
'@types/js-yaml@4.0.9': {}
|
||||
|
||||
'@types/jsdom@21.1.7':
|
||||
dependencies:
|
||||
'@types/node': 20.19.19
|
||||
|
|
|
|||
Loading…
Reference in New Issue