Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
693fe6bb9f | ||
|
|
9f62623684 | ||
|
|
6f6cb14650 | ||
|
|
61196ccbff | ||
|
|
f775fc55e5 | ||
|
|
3cef4fcbac | ||
|
|
a51e539d02 | ||
|
|
7d1a351ed6 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,3 +1,30 @@
|
||||
## [3.22.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.2...v3.22.3) (2025-10-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **number-guesser:** add turn indicators, error feedback, and fix player ordering ([9f62623](https://github.com/antialias/soroban-abacus-flashcards/commit/9f626236845493ef68e1b3626e80efa35637b449))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **arcade:** update docs for unified validator registry ([6f6cb14](https://github.com/antialias/soroban-abacus-flashcards/commit/6f6cb14650ba3636a7e2b036e2a2a9410492e7c3))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** add Number Guesser to game config helpers ([7d1a351](https://github.com/antialias/soroban-abacus-flashcards/commit/7d1a351ed6a1442ae34f6b75d46039bfa77a921b))
|
||||
* **nav:** update types for registry games with nullable gameName ([a51e539](https://github.com/antialias/soroban-abacus-flashcards/commit/a51e539d023681daf639ec104e79079c8ceec98e))
|
||||
|
||||
## [3.22.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.21.0...v3.22.0) (2025-10-16)
|
||||
|
||||
|
||||
|
||||
519
apps/web/docs/AUDIT_MODULAR_GAME_SYSTEM.md
Normal file
519
apps/web/docs/AUDIT_MODULAR_GAME_SYSTEM.md
Normal file
@@ -0,0 +1,519 @@
|
||||
# Modular Game System Audit
|
||||
|
||||
**Date**: 2025-10-15
|
||||
**Updated**: 2025-10-15
|
||||
**Status**: ✅ CRITICAL ISSUE RESOLVED
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The modular game system **now meets its stated intentions** after implementing the unified validator registry. The critical dual registration issue has been resolved.
|
||||
|
||||
**Original Issue**: Client-side implementation (SDK, registry, game definitions) was well-designed, but server-side validation used a hard-coded legacy system, breaking the core premise of modularity.
|
||||
|
||||
**Resolution**: Created unified isomorphic validator registry (`src/lib/arcade/validators.ts`) that serves both client and server needs, with auto-derived GameName type.
|
||||
|
||||
**Verdict**: ✅ **Production Ready** - System is now truly modular with single registration point
|
||||
|
||||
---
|
||||
|
||||
## Intention vs. Reality
|
||||
|
||||
### Stated Intentions
|
||||
|
||||
> "A modular, plugin-based architecture for building multiplayer arcade games"
|
||||
>
|
||||
> **Goals:**
|
||||
> 1. **Modularity**: Each game is self-contained and independently deployable
|
||||
> 2. Games register themselves with a central registry
|
||||
> 3. No need to modify core infrastructure when adding games
|
||||
|
||||
### Current Reality
|
||||
|
||||
✅ **Client-Side**: Fully modular, games use SDK and register themselves
|
||||
❌ **Server-Side**: Hard-coded validator map, requires manual code changes
|
||||
❌ **Overall**: **System is NOT modular** - adding a game requires editing 2 different registries
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### ✅ Issue #1: Dual Registration System (RESOLVED)
|
||||
|
||||
**Original Problem**: Games had to register in TWO separate places:
|
||||
|
||||
1. **Client Registry** (`src/lib/arcade/game-registry.ts`)
|
||||
2. **Server Validator Map** (`src/lib/arcade/validation/index.ts`)
|
||||
|
||||
**Impact**:
|
||||
- ❌ Broke modularity - couldn't just drop in a new game
|
||||
- ❌ Easy to forget one registration, causing runtime errors
|
||||
- ❌ Violated DRY principle
|
||||
- ❌ Two sources of truth for "what games exist"
|
||||
|
||||
**Resolution** (Implemented 2025-10-15):
|
||||
|
||||
Created unified isomorphic validator registry at `src/lib/arcade/validators.ts`:
|
||||
|
||||
```typescript
|
||||
export const validatorRegistry = {
|
||||
matching: matchingGameValidator,
|
||||
'memory-quiz': memoryQuizGameValidator,
|
||||
'number-guesser': numberGuesserValidator,
|
||||
// Add new games here - GameName type auto-updates!
|
||||
} as const
|
||||
|
||||
// Auto-derived type - no manual updates needed!
|
||||
export type GameName = keyof typeof validatorRegistry
|
||||
```
|
||||
|
||||
**Changes Made**:
|
||||
1. ✅ Created `src/lib/arcade/validators.ts` - Unified validator registry (isomorphic)
|
||||
2. ✅ Updated `validation/index.ts` - Now re-exports from unified registry (backwards compatible)
|
||||
3. ✅ Updated `validation/types.ts` - GameName now auto-derived (no more hard-coded union)
|
||||
4. ✅ Updated `session-manager.ts` - Imports from unified registry
|
||||
5. ✅ Updated `socket-server.ts` - Imports from unified registry
|
||||
6. ✅ Updated `route.ts` - Uses `hasValidator()` instead of hard-coded array
|
||||
7. ✅ Updated `game-config-helpers.ts` - Handles ExtendedGameName for legacy games
|
||||
8. ✅ Updated `game-registry.ts` - Added runtime validation check
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Single registration point for validators
|
||||
- ✅ Auto-derived GameName type (no manual updates)
|
||||
- ✅ Type-safe validator access
|
||||
- ✅ Backwards compatible with existing code
|
||||
- ✅ Runtime warnings for registration mismatches
|
||||
|
||||
**Commit**: `refactor(arcade): create unified validator registry to fix dual registration` (9459f37b)
|
||||
|
||||
---
|
||||
|
||||
### ✅ Issue #2: Validators Not Accessible from Registry (RESOLVED)
|
||||
|
||||
**Original Problem**: The `GameDefinition` contained validators, but server couldn't access them because `game-registry.ts` imported React components.
|
||||
|
||||
**Resolution**: Created separate isomorphic validator registry that server can import without pulling in client-only code.
|
||||
|
||||
**How It Works Now**:
|
||||
- `src/lib/arcade/validators.ts` - Isomorphic, server can import safely
|
||||
- `src/lib/arcade/game-registry.ts` - Client-only, imports React components
|
||||
- Both use the same validator instances (verified at runtime)
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Server has direct access to validators
|
||||
- ✅ No need for dual validator maps
|
||||
- ✅ Clear separation: validators (isomorphic) vs UI (client-only)
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Issue #3: Type System Fragmentation
|
||||
|
||||
**Problem**: Multiple overlapping type definitions for same concepts:
|
||||
|
||||
**GameValidator** has THREE definitions:
|
||||
1. `validation/types.ts` - Legacy validator interface
|
||||
2. `game-sdk/types.ts` - SDK validator interface (extends legacy)
|
||||
3. Individual game validators - Implement one or both?
|
||||
|
||||
**GameMove** has TWO type systems:
|
||||
1. `validation/types.ts` - Legacy move types (MatchingFlipCardMove, etc.)
|
||||
2. Game-specific types in each game's `types.ts`
|
||||
|
||||
**GameName** is hard-coded:
|
||||
```typescript
|
||||
// validation/types.ts:9
|
||||
export type GameName = 'matching' | 'memory-quiz' | 'complement-race' | 'number-guesser'
|
||||
```
|
||||
|
||||
This must be manually updated for every new game!
|
||||
|
||||
**Impact**:
|
||||
- Confusing which types to use
|
||||
- Easy to use wrong import
|
||||
- GameName type doesn't auto-update from registry
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Issue #4: Old Games Not Migrated
|
||||
|
||||
**Problem**: Existing games (matching, memory-quiz) still use old structure:
|
||||
|
||||
**Old Pattern** (matching, memory-quiz):
|
||||
```
|
||||
src/app/arcade/matching/
|
||||
├── context/ (Old pattern)
|
||||
│ └── RoomMemoryPairsProvider.tsx
|
||||
└── components/
|
||||
```
|
||||
|
||||
**New Pattern** (number-guesser):
|
||||
```
|
||||
src/arcade-games/number-guesser/
|
||||
├── index.ts (New pattern)
|
||||
├── Validator.ts
|
||||
├── Provider.tsx
|
||||
└── components/
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- Inconsistent codebase structure
|
||||
- Two different patterns developers must understand
|
||||
- Documentation shows new pattern, but most games use old pattern
|
||||
- Confusing for new developers
|
||||
|
||||
**Evidence**:
|
||||
- `src/app/arcade/matching/` - Uses old structure
|
||||
- `src/app/arcade/memory-quiz/` - Uses old structure
|
||||
- `src/arcade-games/number-guesser/` - Uses new structure
|
||||
|
||||
---
|
||||
|
||||
### ✅ Issue #5: Manual GameName Type Updates (RESOLVED)
|
||||
|
||||
**Original Problem**: `GameName` type was a hard-coded union that had to be manually updated for each new game.
|
||||
|
||||
**Resolution**: Changed validator registry from Map to const object, enabling type derivation:
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/validators.ts
|
||||
export const validatorRegistry = {
|
||||
matching: matchingGameValidator,
|
||||
'memory-quiz': memoryQuizGameValidator,
|
||||
'number-guesser': numberGuesserValidator,
|
||||
// Add new games here...
|
||||
} as const
|
||||
|
||||
// Auto-derived! No manual updates needed!
|
||||
export type GameName = keyof typeof validatorRegistry
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ GameName type updates automatically when adding to registry
|
||||
- ✅ Impossible to forget type update (it's derived)
|
||||
- ✅ Single registration step (just add to validatorRegistry)
|
||||
- ✅ Type-safe throughout codebase
|
||||
|
||||
---
|
||||
|
||||
## Secondary Issues
|
||||
|
||||
### Issue #6: No Server-Side Registry Access
|
||||
|
||||
**Problem**: Server code cannot import `game-registry.ts` because it contains React components.
|
||||
|
||||
**Why**:
|
||||
- `GameDefinition` includes `Provider` and `GameComponent` (React components)
|
||||
- Server-side code runs in Node.js, can't import React components
|
||||
- No way to access just the validator from registry
|
||||
|
||||
**Potential Solutions**:
|
||||
1. Split registry into isomorphic and client-only parts
|
||||
2. Separate validator registration from game registration
|
||||
3. Use conditional exports in package.json
|
||||
|
||||
---
|
||||
|
||||
### Issue #7: Documentation Doesn't Match Reality
|
||||
|
||||
**Problem**: Documentation describes a fully modular system, but reality requires manual edits in multiple places.
|
||||
|
||||
**From README.md**:
|
||||
> "Step 7: Register Game - Add to src/lib/arcade/game-registry.ts"
|
||||
|
||||
**Missing Steps**:
|
||||
- Also add to `validation/index.ts` validator map
|
||||
- Also add to `GameName` type union
|
||||
- Import validator in server files
|
||||
|
||||
**Impact**: Developers follow docs, game doesn't work, confusion ensues.
|
||||
|
||||
---
|
||||
|
||||
### Issue #8: No Validation of Registered Games
|
||||
|
||||
**Problem**: Registration is type-safe but has no runtime validation:
|
||||
|
||||
```typescript
|
||||
registerGame(numberGuesserGame) // No validation that validator works
|
||||
```
|
||||
|
||||
**Missing Checks**:
|
||||
- Does validator implement all required methods?
|
||||
- Does manifest match expected schema?
|
||||
- Are all required fields present?
|
||||
- Does validator.getInitialState() return valid state?
|
||||
|
||||
**Impact**: Bugs only caught at runtime when game is played.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
### Solution 1: Unified Server-Side Registry (RECOMMENDED)
|
||||
|
||||
**Create isomorphic validator registry**:
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/validators.ts (NEW FILE - isomorphic)
|
||||
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
|
||||
import { matchingGameValidator } from '@/lib/arcade/validation/MatchingGameValidator'
|
||||
// ... other validators
|
||||
|
||||
export const validatorRegistry = new Map([
|
||||
['number-guesser', numberGuesserValidator],
|
||||
['matching', matchingGameValidator],
|
||||
// ...
|
||||
])
|
||||
|
||||
export function getValidator(gameName: string) {
|
||||
const validator = validatorRegistry.get(gameName)
|
||||
if (!validator) throw new Error(`No validator for game: ${gameName}`)
|
||||
return validator
|
||||
}
|
||||
|
||||
export type GameName = keyof typeof validatorRegistry // Auto-derived!
|
||||
```
|
||||
|
||||
**Update game-registry.ts** to use this:
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/game-registry.ts
|
||||
import { getValidator } from './validators'
|
||||
|
||||
export function registerGame(game: GameDefinition) {
|
||||
const { name } = game.manifest
|
||||
|
||||
// Verify validator is registered server-side
|
||||
const validator = getValidator(name)
|
||||
if (validator !== game.validator) {
|
||||
console.warn(`[Registry] Validator mismatch for ${name}`)
|
||||
}
|
||||
|
||||
registry.set(name, game)
|
||||
}
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Single source of truth for validators
|
||||
- Auto-derived GameName type
|
||||
- Client and server use same validator
|
||||
- Only one registration needed
|
||||
|
||||
**Cons**:
|
||||
- Still requires manual import in validators.ts
|
||||
- Doesn't solve "drop in a game" fully
|
||||
|
||||
---
|
||||
|
||||
### Solution 2: Code Generation
|
||||
|
||||
**Auto-generate validator registry from file system**:
|
||||
|
||||
```typescript
|
||||
// scripts/generate-registry.ts
|
||||
// Scans src/arcade-games/**/Validator.ts
|
||||
// Generates validators.ts and game-registry imports
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Truly modular - just add folder, run build
|
||||
- No manual registration
|
||||
- Auto-derived types
|
||||
|
||||
**Cons**:
|
||||
- Build-time complexity
|
||||
- Magic (harder to understand)
|
||||
- May not work with all bundlers
|
||||
|
||||
---
|
||||
|
||||
### Solution 3: Split GameDefinition
|
||||
|
||||
**Separate client and server concerns**:
|
||||
|
||||
```typescript
|
||||
// Isomorphic (client + server)
|
||||
export interface GameValidatorDefinition {
|
||||
name: string
|
||||
validator: GameValidator
|
||||
defaultConfig: GameConfig
|
||||
}
|
||||
|
||||
// Client-only
|
||||
export interface GameUIDefinition {
|
||||
name: string
|
||||
manifest: GameManifest
|
||||
Provider: GameProviderComponent
|
||||
GameComponent: GameComponent
|
||||
}
|
||||
|
||||
// Combined (client-only)
|
||||
export interface GameDefinition extends GameValidatorDefinition, GameUIDefinition {}
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Clear separation of concerns
|
||||
- Server can import just validator definition
|
||||
- Type-safe
|
||||
|
||||
**Cons**:
|
||||
- More complexity
|
||||
- Still requires two registries
|
||||
|
||||
---
|
||||
|
||||
## Immediate Action Items
|
||||
|
||||
### Critical (Do Before Next Game)
|
||||
|
||||
1. **✅ Document the dual registration requirement** (COMPLETED)
|
||||
- ✅ Update README with both registration steps
|
||||
- ✅ Add troubleshooting section for "game not found" errors
|
||||
- ✅ Document unified validator registry in Step 7
|
||||
|
||||
2. **✅ Unify validator registration** (COMPLETED 2025-10-15)
|
||||
- ✅ Chose Solution 1 (Unified Server-Side Registry)
|
||||
- ✅ Implemented unified registry (src/lib/arcade/validators.ts)
|
||||
- ✅ Updated session-manager.ts and socket-server.ts
|
||||
- ✅ Tested with number-guesser (no TypeScript errors)
|
||||
|
||||
3. **✅ Auto-derive GameName type** (COMPLETED 2025-10-15)
|
||||
- ✅ Removed hard-coded union
|
||||
- ✅ Derive from validator registry using `keyof typeof`
|
||||
- ✅ Updated all usages (backwards compatible via re-exports)
|
||||
|
||||
### High Priority
|
||||
|
||||
4. **🟡 Migrate old games to new pattern**
|
||||
- Move matching to `arcade-games/matching/`
|
||||
- Move memory-quiz to `arcade-games/memory-quiz/`
|
||||
- Update imports and tests
|
||||
- OR document that old games use old pattern (transitional)
|
||||
|
||||
5. **🟡 Add validator registration validation**
|
||||
- Runtime check in registerGame()
|
||||
- Warn if validator missing
|
||||
- Validate manifest schema
|
||||
|
||||
### Medium Priority
|
||||
|
||||
6. **🟢 Clean up type definitions**
|
||||
- Consolidate GameValidator types
|
||||
- Single source of truth for GameMove
|
||||
- Clear documentation on which to use
|
||||
|
||||
7. **🟢 Update documentation**
|
||||
- Add "dual registry" warning
|
||||
- Update step-by-step guide
|
||||
- Add troubleshooting for common mistakes
|
||||
|
||||
---
|
||||
|
||||
## Architectural Debt
|
||||
|
||||
### Technical Debt Accumulated
|
||||
|
||||
1. **Old validation system** (`validation/types.ts`, `validation/index.ts`)
|
||||
- Used by server-side code
|
||||
- Hard-coded game list
|
||||
- No migration path documented
|
||||
|
||||
2. **Mixed game structures** (old in `app/arcade/`, new in `arcade-games/`)
|
||||
- Confusing for developers
|
||||
- Inconsistent imports
|
||||
- Harder to maintain
|
||||
|
||||
3. **Type fragmentation** (3 GameValidator definitions)
|
||||
- Unclear which to use
|
||||
- Potential for bugs
|
||||
- Harder to refactor
|
||||
|
||||
### Migration Path
|
||||
|
||||
**Option A: Big Bang** (Risky)
|
||||
- Migrate all games to new structure in one PR
|
||||
- Update server to use unified registry
|
||||
- High risk of breakage
|
||||
|
||||
**Option B: Incremental** (Safer)
|
||||
- Document dual registration as "current reality"
|
||||
- Create unified validator registry (doesn't break old games)
|
||||
- Slowly migrate old games one by one
|
||||
- Eventually deprecate old validation system
|
||||
|
||||
**Recommendation**: Option B (Incremental)
|
||||
|
||||
---
|
||||
|
||||
## Compliance with Intentions
|
||||
|
||||
| Intention | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Modularity | ✅ Pass | Single registration in validators.ts + game-registry.ts |
|
||||
| Self-registration | ✅ Pass | Both client and server use unified registry |
|
||||
| Type safety | ✅ Pass | Good TypeScript coverage + auto-derived GameName |
|
||||
| No core changes | ⚠️ Improved | Must edit validators.ts, but one central file |
|
||||
| Drop-in games | ⚠️ Improved | Two registration points (validator + game def) |
|
||||
| Stable SDK API | ✅ Pass | SDK is well-designed and consistent |
|
||||
| Clear patterns | ⚠️ Partial | New pattern is clear, but old games don't follow it |
|
||||
|
||||
**Original Grade**: **D** (Failed core modularity requirement)
|
||||
**Current Grade**: **B+** (Modularity achieved, some legacy migration pending)
|
||||
|
||||
---
|
||||
|
||||
## Positive Aspects (What Works Well)
|
||||
|
||||
1. **✅ SDK Design** - Clean, well-documented, type-safe
|
||||
2. **✅ Client-Side Registry** - Simple, effective pattern
|
||||
3. **✅ GameDefinition Structure** - Good separation of concerns
|
||||
4. **✅ Documentation** - Comprehensive (though doesn't match reality)
|
||||
5. **✅ defineGame() Helper** - Makes game creation easy
|
||||
6. **✅ Type Safety** - Excellent TypeScript coverage
|
||||
7. **✅ Number Guesser Example** - Good reference implementation
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Immediate (This Sprint)
|
||||
|
||||
1. ✅ **Document current reality** - Update docs to show both registrations required
|
||||
2. 🔴 **Create unified validator registry** - Implement Solution 1
|
||||
3. 🔴 **Update server to use unified registry** - Modify session-manager.ts and socket-server.ts
|
||||
|
||||
### Next Sprint
|
||||
|
||||
4. 🟡 **Migrate one old game** - Move matching to new structure as proof of concept
|
||||
5. 🟡 **Add registration validation** - Runtime checks for validator consistency
|
||||
6. 🟡 **Auto-derive GameName** - Remove hard-coded type union
|
||||
|
||||
### Future
|
||||
|
||||
7. 🟢 **Code generation** - Explore automated registry generation
|
||||
8. 🟢 **Plugin system** - True drop-in games with discovery
|
||||
9. 🟢 **Deprecate old validation system** - Once all games migrated
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The modular game system has a **solid foundation** but is **not truly modular** due to server-side technical debt. The client-side implementation is excellent, but the server still uses a legacy hard-coded validation system.
|
||||
|
||||
**Status**: Needs significant refactoring before claiming "modular architecture"
|
||||
|
||||
**Path Forward**: Implement unified validator registry (Solution 1), then incrementally migrate old games.
|
||||
|
||||
**Risk**: If we add more games before fixing this, technical debt will compound.
|
||||
|
||||
---
|
||||
|
||||
*This audit was conducted by reviewing:*
|
||||
- `src/lib/arcade/game-registry.ts`
|
||||
- `src/lib/arcade/validation/index.ts`
|
||||
- `src/lib/arcade/session-manager.ts`
|
||||
- `src/socket-server.ts`
|
||||
- `src/lib/arcade/game-sdk/`
|
||||
- `src/arcade-games/number-guesser/`
|
||||
- Documentation in `docs/` and `src/arcade-games/README.md`
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
816
apps/web/src/arcade-games/README.md
Normal file
816
apps/web/src/arcade-games/README.md
Normal file
@@ -0,0 +1,816 @@
|
||||
# Arcade Game System
|
||||
|
||||
A modular, plugin-based architecture for building multiplayer arcade games with real-time synchronization.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Architecture](#architecture)
|
||||
- [Game SDK](#game-sdk)
|
||||
- [Creating a New Game](#creating-a-new-game)
|
||||
- [File Structure](#file-structure)
|
||||
- [Examples](#examples)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Modularity**: Each game is self-contained and independently deployable
|
||||
2. **Type Safety**: Full TypeScript support with compile-time validation
|
||||
3. **Real-time Sync**: Built-in multiplayer support via WebSocket
|
||||
4. **Optimistic Updates**: Instant client feedback with server validation
|
||||
5. **Consistent UX**: Shared navigation, player management, and room features
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Plugin Architecture**: Games register themselves with a central registry
|
||||
- **Stable SDK API**: Games only import from `@/lib/arcade/game-sdk`
|
||||
- **Server-side Validation**: All moves validated server-side with client rollback
|
||||
- **Automatic State Sync**: Multi-client synchronization handled automatically
|
||||
- **Turn Indicators**: Built-in UI for showing active player
|
||||
- **Error Handling**: Standardized error feedback to users
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### System Components
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Game Registry │
|
||||
│ - Registers all available games │
|
||||
│ - Provides game discovery │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Game SDK │
|
||||
│ - Stable API surface for games │
|
||||
│ - React hooks (useArcadeSession, useRoomData, etc.) │
|
||||
│ - Type definitions and utilities │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Individual Games │
|
||||
│ number-guesser/ │
|
||||
│ ├── index.ts (Game definition) │
|
||||
│ ├── Validator.ts (Server validation) │
|
||||
│ ├── Provider.tsx (Client state management) │
|
||||
│ ├── GameComponent.tsx (Main UI) │
|
||||
│ ├── types.ts (TypeScript types) │
|
||||
│ └── components/ (Phase UIs) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
User Action → Provider (sendMove)
|
||||
↓
|
||||
useArcadeSession
|
||||
↓
|
||||
Optimistic Update (instant UI feedback)
|
||||
↓
|
||||
WebSocket → Server
|
||||
↓
|
||||
Validator.validateMove()
|
||||
↓
|
||||
✓ Valid: State Update → Broadcast
|
||||
✗ Invalid: Reject → Client Rollback
|
||||
↓
|
||||
Client receives validated state
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Game SDK
|
||||
|
||||
### Core API Surface
|
||||
|
||||
```typescript
|
||||
import {
|
||||
// Types
|
||||
type GameDefinition,
|
||||
type GameValidator,
|
||||
type GameState,
|
||||
type GameMove,
|
||||
type GameConfig,
|
||||
type ValidationResult,
|
||||
|
||||
// React Hooks
|
||||
useArcadeSession,
|
||||
useRoomData,
|
||||
useGameMode,
|
||||
useViewerId,
|
||||
useUpdateGameConfig,
|
||||
|
||||
// Utilities
|
||||
defineGame,
|
||||
buildPlayerMetadata,
|
||||
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
#### GameDefinition
|
||||
|
||||
Complete description of a game:
|
||||
|
||||
```typescript
|
||||
interface GameDefinition<TConfig, TState, TMove> {
|
||||
manifest: GameManifest // Display info, max players, etc.
|
||||
Provider: GameProviderComponent // React context provider
|
||||
GameComponent: GameComponent // Main UI component
|
||||
validator: GameValidator // Server-side validation
|
||||
defaultConfig: TConfig // Default game settings
|
||||
}
|
||||
```
|
||||
|
||||
#### GameState
|
||||
|
||||
The complete game state that's synchronized across all clients:
|
||||
|
||||
```typescript
|
||||
interface GameState {
|
||||
gamePhase: string // Current phase (setup, playing, results)
|
||||
activePlayers: string[] // Array of player IDs
|
||||
playerMetadata: Record<string, PlayerMeta> // Player info (name, emoji, etc.)
|
||||
// ... game-specific state
|
||||
}
|
||||
```
|
||||
|
||||
#### GameMove
|
||||
|
||||
Actions that players take, validated server-side:
|
||||
|
||||
```typescript
|
||||
interface GameMove {
|
||||
type: string // Move type (e.g., 'FLIP_CARD', 'MAKE_GUESS')
|
||||
playerId: string // Player making the move
|
||||
userId: string // User ID (for authentication)
|
||||
timestamp: number // Client timestamp
|
||||
data: Record<string, unknown> // Move-specific payload
|
||||
}
|
||||
```
|
||||
|
||||
#### GameValidator
|
||||
|
||||
Server-side validation logic:
|
||||
|
||||
```typescript
|
||||
interface GameValidator<TState, TMove> {
|
||||
validateMove(state: TState, move: TMove): ValidationResult
|
||||
isGameComplete(state: TState): boolean
|
||||
getInitialState(config: unknown): TState
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating a New Game
|
||||
|
||||
### Step 1: Create Game Directory
|
||||
|
||||
```bash
|
||||
mkdir -p src/arcade-games/my-game/components
|
||||
```
|
||||
|
||||
### Step 2: Define Types (`types.ts`)
|
||||
|
||||
```typescript
|
||||
import type { GameConfig, GameMove, GameState } from '@/lib/arcade/game-sdk'
|
||||
|
||||
// Game configuration (persisted to database)
|
||||
export interface MyGameConfig extends GameConfig {
|
||||
difficulty: number
|
||||
timer: number
|
||||
}
|
||||
|
||||
// Game state (synchronized across clients)
|
||||
export interface MyGameState extends GameState {
|
||||
gamePhase: 'setup' | 'playing' | 'results'
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, PlayerMetadata>
|
||||
currentPlayer: string
|
||||
score: Record<string, number>
|
||||
// ... your game-specific state
|
||||
}
|
||||
|
||||
// Move types
|
||||
export type MyGameMove =
|
||||
| { type: 'START_GAME'; playerId: string; userId: string; timestamp: number; data: { activePlayers: string[] } }
|
||||
| { type: 'MAKE_MOVE'; playerId: string; userId: string; timestamp: number; data: { /* move data */ } }
|
||||
| { type: 'END_GAME'; playerId: string; userId: string; timestamp: number; data: {} }
|
||||
```
|
||||
|
||||
### Step 3: Create Validator (`Validator.ts`)
|
||||
|
||||
```typescript
|
||||
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
|
||||
import type { MyGameState, MyGameMove } from './types'
|
||||
|
||||
export class MyGameValidator implements GameValidator<MyGameState, MyGameMove> {
|
||||
validateMove(state: MyGameState, move: MyGameMove): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers)
|
||||
case 'MAKE_MOVE':
|
||||
return this.validateMakeMove(state, move.playerId, move.data)
|
||||
default:
|
||||
return { valid: false, error: 'Unknown move type' }
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGame(state: MyGameState, activePlayers: string[]): ValidationResult {
|
||||
if (activePlayers.length < 2) {
|
||||
return { valid: false, error: 'Need at least 2 players' }
|
||||
}
|
||||
|
||||
const newState: MyGameState = {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
activePlayers,
|
||||
currentPlayer: activePlayers[0],
|
||||
score: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// ... more validation methods
|
||||
|
||||
isGameComplete(state: MyGameState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
||||
getInitialState(config: unknown): MyGameState {
|
||||
const { difficulty, timer } = config as MyGameConfig
|
||||
return {
|
||||
difficulty,
|
||||
timer,
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
currentPlayer: '',
|
||||
score: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const myGameValidator = new MyGameValidator()
|
||||
```
|
||||
|
||||
### Step 4: Create Provider (`Provider.tsx`)
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo } from 'react'
|
||||
import {
|
||||
type GameMove,
|
||||
buildPlayerMetadata,
|
||||
useArcadeSession,
|
||||
useGameMode,
|
||||
useRoomData,
|
||||
useViewerId,
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
import type { MyGameState } from './types'
|
||||
|
||||
interface MyGameContextValue {
|
||||
state: MyGameState
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
makeMove: (data: any) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
const MyGameContext = createContext<MyGameContextValue | null>(null)
|
||||
|
||||
export function useMyGame() {
|
||||
const context = useContext(MyGameContext)
|
||||
if (!context) throw new Error('useMyGame must be used within MyGameProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
export function MyGameProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
|
||||
// Get active players as array (keep Set iteration order to match UI display)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
const initialState = useMemo(() => ({
|
||||
difficulty: 1,
|
||||
timer: 30,
|
||||
gamePhase: 'setup' as const,
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
currentPlayer: '',
|
||||
score: {},
|
||||
}), [])
|
||||
|
||||
const { state, sendMove, exitSession, lastError, clearError } =
|
||||
useArcadeSession<MyGameState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: (state, move) => state, // Server handles all updates
|
||||
})
|
||||
|
||||
const startGame = useCallback(() => {
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: activePlayers[0],
|
||||
userId: viewerId || '',
|
||||
data: { activePlayers, playerMetadata },
|
||||
})
|
||||
}, [activePlayers, players, viewerId, sendMove])
|
||||
|
||||
const makeMove = useCallback((data: any) => {
|
||||
sendMove({
|
||||
type: 'MAKE_MOVE',
|
||||
playerId: state.currentPlayer,
|
||||
userId: viewerId || '',
|
||||
data,
|
||||
})
|
||||
}, [state.currentPlayer, viewerId, sendMove])
|
||||
|
||||
return (
|
||||
<MyGameContext.Provider value={{
|
||||
state,
|
||||
lastError,
|
||||
startGame,
|
||||
makeMove,
|
||||
clearError,
|
||||
exitSession,
|
||||
}}>
|
||||
{children}
|
||||
</MyGameContext.Provider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Create Game Component (`GameComponent.tsx`)
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useMyGame } from '../Provider'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
import { PlayingPhase } from './PlayingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession } = useMyGame()
|
||||
|
||||
// Determine whose turn it is for the turn indicator
|
||||
const currentPlayerId = state.gamePhase === 'playing' ? state.currentPlayer : undefined
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="My Game"
|
||||
navEmoji="🎮"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
currentPlayerId={currentPlayerId}
|
||||
playerScores={state.score}
|
||||
onExitSession={() => {
|
||||
exitSession()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Define Game (`index.ts`)
|
||||
|
||||
```typescript
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { MyGameProvider } from './Provider'
|
||||
import type { MyGameConfig, MyGameMove, MyGameState } from './types'
|
||||
import { myGameValidator } from './Validator'
|
||||
|
||||
const manifest: GameManifest = {
|
||||
name: 'my-game',
|
||||
displayName: 'My Awesome Game',
|
||||
icon: '🎮',
|
||||
description: 'A fun multiplayer game',
|
||||
longDescription: 'Detailed description of gameplay...',
|
||||
maxPlayers: 4,
|
||||
difficulty: 'Beginner',
|
||||
chips: ['👥 Multiplayer', '🎲 Turn-Based'],
|
||||
color: 'blue',
|
||||
gradient: 'linear-gradient(135deg, #bfdbfe, #93c5fd)',
|
||||
borderColor: 'blue.200',
|
||||
available: true,
|
||||
}
|
||||
|
||||
const defaultConfig: MyGameConfig = {
|
||||
difficulty: 1,
|
||||
timer: 30,
|
||||
}
|
||||
|
||||
export const myGame = defineGame<MyGameConfig, MyGameState, MyGameMove>({
|
||||
manifest,
|
||||
Provider: MyGameProvider,
|
||||
GameComponent,
|
||||
validator: myGameValidator,
|
||||
defaultConfig,
|
||||
})
|
||||
```
|
||||
|
||||
### Step 7: Register Game
|
||||
|
||||
#### 7a. Register Validator (Server-Side)
|
||||
|
||||
Add your validator to the unified registry in `src/lib/arcade/validators.ts`:
|
||||
|
||||
```typescript
|
||||
import { myGameValidator } from '@/arcade-games/my-game/Validator'
|
||||
|
||||
export const validatorRegistry = {
|
||||
matching: matchingGameValidator,
|
||||
'memory-quiz': memoryQuizGameValidator,
|
||||
'number-guesser': numberGuesserValidator,
|
||||
'my-game': myGameValidator, // Add your game here!
|
||||
// GameName type will auto-update from these keys
|
||||
} as const
|
||||
```
|
||||
|
||||
**Why**: The validator registry is isomorphic (runs on both client and server) and serves as the single source of truth for all game validators. Adding your validator here automatically:
|
||||
- Makes it available for server-side move validation
|
||||
- Updates the `GameName` type (no manual type updates needed!)
|
||||
- Enables your game for multiplayer rooms
|
||||
|
||||
#### 7b. Register Game Definition (Client-Side)
|
||||
|
||||
Add to `src/lib/arcade/game-registry.ts`:
|
||||
|
||||
```typescript
|
||||
import { myGame } from '@/arcade-games/my-game'
|
||||
|
||||
registerGame(myGame)
|
||||
```
|
||||
|
||||
**Why**: The game registry is client-only and connects your game's UI components (Provider, GameComponent) with the arcade system. Registration happens on client init and verifies that your validator is also registered server-side.
|
||||
|
||||
**Verification**: When you register a game, the registry will warn you if:
|
||||
- ⚠️ The validator is missing from `validators.ts`
|
||||
- ⚠️ The validator instance doesn't match (different imports)
|
||||
|
||||
**Important**: Both steps are required for a working game. The validator registry handles server logic, while the game registry handles client UI.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/arcade-games/my-game/
|
||||
├── index.ts # Game definition and export
|
||||
├── Validator.ts # Server-side move validation
|
||||
├── Provider.tsx # Client state management
|
||||
├── GameComponent.tsx # Main UI wrapper
|
||||
├── types.ts # TypeScript type definitions
|
||||
└── components/
|
||||
├── SetupPhase.tsx # Setup/lobby UI
|
||||
├── PlayingPhase.tsx # Main gameplay UI
|
||||
└── ResultsPhase.tsx # End game/scores UI
|
||||
```
|
||||
|
||||
### File Responsibilities
|
||||
|
||||
| File | Purpose | Runs On |
|
||||
|------|---------|---------|
|
||||
| `index.ts` | Game registration | Both |
|
||||
| `Validator.ts` | Move validation, game logic | **Server only** |
|
||||
| `Provider.tsx` | State management, API calls | Client only |
|
||||
| `GameComponent.tsx` | Navigation, phase routing | Client only |
|
||||
| `types.ts` | Shared type definitions | Both |
|
||||
| `components/*` | UI for each game phase | Client only |
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Number Guesser (Turn-Based)
|
||||
|
||||
See `src/arcade-games/number-guesser/` for a complete example of:
|
||||
- Turn-based gameplay (chooser → guessers)
|
||||
- Player rotation logic
|
||||
- Round management
|
||||
- Score tracking
|
||||
- Hot/cold feedback system
|
||||
- Error handling and user feedback
|
||||
|
||||
**Key Patterns:**
|
||||
- Setting `currentPlayerId` for turn indicators
|
||||
- Rotating turns in validator
|
||||
- Handling round vs. game completion
|
||||
- Type coercion for JSON-serialized numbers
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Player Ordering Consistency
|
||||
|
||||
**Problem**: Sets don't guarantee order, causing mismatch between UI and game logic.
|
||||
|
||||
**Solution**: Use `Array.from(activePlayerIds)` without sorting in both UI and game logic.
|
||||
|
||||
```typescript
|
||||
// In Provider
|
||||
const activePlayers = Array.from(activePlayerIds) // NO .sort()
|
||||
|
||||
// In Validator
|
||||
const newState = {
|
||||
...state,
|
||||
currentPlayer: activePlayers[0], // First in Set order = first in UI
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Type Coercion for Numbers
|
||||
|
||||
**Problem**: WebSocket JSON serialization converts numbers to strings.
|
||||
|
||||
**Solution**: Explicitly coerce in validator:
|
||||
|
||||
```typescript
|
||||
validateMove(state: MyGameState, move: MyGameMove): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'MAKE_GUESS':
|
||||
return this.validateGuess(state, Number(move.data.guess)) // Coerce!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Error Feedback
|
||||
|
||||
**Problem**: Users don't see why their moves were rejected.
|
||||
|
||||
**Solution**: Use `lastError` from `useArcadeSession`:
|
||||
|
||||
```typescript
|
||||
const { state, lastError, clearError } = useArcadeSession(...)
|
||||
|
||||
// Auto-dismiss after 5 seconds
|
||||
useEffect(() => {
|
||||
if (lastError) {
|
||||
const timeout = setTimeout(() => clearError(), 5000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [lastError, clearError])
|
||||
|
||||
// Show in UI
|
||||
{lastError && (
|
||||
<div className="error-banner">
|
||||
<div>⚠️ Move Rejected</div>
|
||||
<div>{lastError}</div>
|
||||
<button onClick={clearError}>Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 4. Turn Indicators
|
||||
|
||||
**Problem**: Players don't know whose turn it is.
|
||||
|
||||
**Solution**: Pass `currentPlayerId` to `PageWithNav`:
|
||||
|
||||
```typescript
|
||||
<PageWithNav
|
||||
currentPlayerId={state.currentPlayer}
|
||||
playerScores={state.scores}
|
||||
>
|
||||
```
|
||||
|
||||
### 5. Server-Only Logic
|
||||
|
||||
**Problem**: Client can cheat by modifying local state.
|
||||
|
||||
**Solution**: All game logic in validator, client uses `applyMove: (state) => state`:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Client calculates winner
|
||||
const { state, sendMove } = useArcadeSession({
|
||||
applyMove: (state, move) => {
|
||||
if (move.type === 'SCORE') {
|
||||
return { ...state, winner: calculateWinner(state) } // Cheatable!
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ✅ GOOD: Server calculates everything
|
||||
const { state, sendMove } = useArcadeSession({
|
||||
applyMove: (state, move) => state // Client just waits for server
|
||||
})
|
||||
```
|
||||
|
||||
### 6. Phase Management
|
||||
|
||||
Use discriminated union for type-safe phase rendering:
|
||||
|
||||
```typescript
|
||||
type GamePhase = 'setup' | 'playing' | 'results'
|
||||
|
||||
interface MyGameState {
|
||||
gamePhase: GamePhase
|
||||
// ...
|
||||
}
|
||||
|
||||
// In GameComponent
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Player not found" errors
|
||||
|
||||
**Cause**: Player IDs from `useGameMode()` don't match server state.
|
||||
|
||||
**Fix**: Always use `buildPlayerMetadata()` helper:
|
||||
|
||||
```typescript
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId)
|
||||
```
|
||||
|
||||
### Turn indicator not showing
|
||||
|
||||
**Cause**: `currentPlayerId` not passed or doesn't match player IDs in UI.
|
||||
|
||||
**Fix**: Verify player order matches between game state and `activePlayerIds`:
|
||||
|
||||
```typescript
|
||||
// Both should use same source without sorting
|
||||
const activePlayers = Array.from(activePlayerIds) // Provider
|
||||
const activePlayerList = Array.from(activePlayers) // PageWithNav
|
||||
```
|
||||
|
||||
### Moves rejected with type errors
|
||||
|
||||
**Cause**: JSON serialization converts numbers to strings.
|
||||
|
||||
**Fix**: Add `Number()` coercion in validator:
|
||||
|
||||
```typescript
|
||||
case 'SET_VALUE':
|
||||
return this.validateValue(state, Number(move.data.value))
|
||||
```
|
||||
|
||||
### State not syncing across clients
|
||||
|
||||
**Cause**: Not using `useArcadeSession` correctly.
|
||||
|
||||
**Fix**: Ensure `roomId` is passed:
|
||||
|
||||
```typescript
|
||||
const { state, sendMove } = useArcadeSession({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // Required for room sync!
|
||||
initialState,
|
||||
applyMove: (state) => state,
|
||||
})
|
||||
```
|
||||
|
||||
### Game not appearing in selector
|
||||
|
||||
**Cause**: Not registered or `available: false`.
|
||||
|
||||
**Fix**:
|
||||
1. Add to `game-registry.ts`: `registerGame(myGame)`
|
||||
2. Set `available: true` in manifest
|
||||
3. Verify no console errors on import
|
||||
|
||||
### Config changes not taking effect
|
||||
|
||||
**Cause**: State sync timing - validator uses old state while config is being updated.
|
||||
|
||||
**Context**: When you change game config (e.g., min/max numbers), there's a brief window where:
|
||||
1. Client updates config in database
|
||||
2. Config change hasn't propagated to server state yet
|
||||
3. Moves are validated against old state
|
||||
|
||||
**Fix**: Ensure config changes trigger state reset or are applied atomically:
|
||||
|
||||
```typescript
|
||||
// When changing config, also update initialState
|
||||
const setConfig = useCallback((field, value) => {
|
||||
sendMove({ type: 'SET_CONFIG', data: { field, value } })
|
||||
|
||||
// Persist to database for next session
|
||||
if (roomData?.id) {
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: {
|
||||
...roomData.gameConfig,
|
||||
'my-game': { ...currentConfig, [field]: value }
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [sendMove, updateGameConfig, roomData])
|
||||
```
|
||||
|
||||
**Best Practice**: Make config changes only during setup phase, before game starts.
|
||||
|
||||
### Debugging validation errors
|
||||
|
||||
**Problem**: Moves rejected but unclear why (especially type-related issues).
|
||||
|
||||
**Solution**: Add debug logging in validator:
|
||||
|
||||
```typescript
|
||||
private validateGuess(state: State, guess: number): ValidationResult {
|
||||
// Debug logging
|
||||
console.log('[MyGame] Validating guess:', {
|
||||
guess,
|
||||
guessType: typeof guess, // Check if it's a string!
|
||||
min: state.minNumber,
|
||||
minType: typeof state.minNumber,
|
||||
max: state.maxNumber,
|
||||
maxType: typeof state.maxNumber,
|
||||
})
|
||||
|
||||
if (guess < state.minNumber || guess > state.maxNumber) {
|
||||
return { valid: false, error: `Guess must be between ${state.minNumber} and ${state.maxNumber}` }
|
||||
}
|
||||
|
||||
// ... rest of validation
|
||||
}
|
||||
```
|
||||
|
||||
**What to check:**
|
||||
1. **Browser console**: Look for `[ArcadeSession] Move rejected by server:` messages
|
||||
2. **Server logs**: Check validator console.log output for types and values
|
||||
3. **Type mismatches**: Numbers becoming strings is the #1 issue
|
||||
4. **State sync**: Is the validator using the state you expect?
|
||||
|
||||
**Common debugging workflow:**
|
||||
1. Move rejected → Check browser console for error message
|
||||
2. Error unclear → Add console.log to validator
|
||||
3. Restart server → See debug output when move is made
|
||||
4. Compare expected vs. actual values/types
|
||||
5. Add `Number()` coercion if types don't match
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Game SDK**: `src/lib/arcade/game-sdk/`
|
||||
- **Registry**: `src/lib/arcade/game-registry.ts`
|
||||
- **Example Game**: `src/arcade-games/number-guesser/`
|
||||
- **Validation Types**: `src/lib/arcade/validation/types.ts`
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I use external libraries in my game?**
|
||||
A: Yes, but install them in the workspace package.json. Games should be self-contained.
|
||||
|
||||
**Q: How do I add game configuration that persists?**
|
||||
A: Use `useUpdateGameConfig()` to save to room:
|
||||
|
||||
```typescript
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: {
|
||||
...roomData.gameConfig,
|
||||
'my-game': { difficulty: 5 }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Q: Can I have asymmetric player roles?**
|
||||
A: Yes! See Number Guesser's chooser/guesser pattern.
|
||||
|
||||
**Q: How do I handle real-time timers?**
|
||||
A: Store `startTime` in state, use client-side countdown, server validates elapsed time.
|
||||
|
||||
**Q: What's the difference between `playerId` and `userId`?**
|
||||
A: `userId` is the user account, `playerId` is the avatar/character in the game. One user can control multiple players.
|
||||
@@ -22,12 +22,14 @@ import type { NumberGuesserState } from './types'
|
||||
*/
|
||||
interface NumberGuesserContextValue {
|
||||
state: NumberGuesserState
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
chooseNumber: (number: number) => void
|
||||
makeGuess: (guess: number) => void
|
||||
nextRound: () => void
|
||||
goToSetup: () => void
|
||||
setConfig: (field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
@@ -62,7 +64,7 @@ export function NumberGuesserProvider({ children }: { children: ReactNode }) {
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active players as array
|
||||
// Get active players as array (keep Set iteration order to match UI display)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Merge saved config from room
|
||||
@@ -90,12 +92,13 @@ export function NumberGuesserProvider({ children }: { children: ReactNode }) {
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, exitSession } = useArcadeSession<NumberGuesserState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
const { state, sendMove, exitSession, lastError, clearError } =
|
||||
useArcadeSession<NumberGuesserState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
@@ -195,12 +198,14 @@ export function NumberGuesserProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const contextValue: NumberGuesserContextValue = {
|
||||
state,
|
||||
lastError,
|
||||
startGame,
|
||||
chooseNumber,
|
||||
makeGuess,
|
||||
nextRound,
|
||||
goToSetup,
|
||||
setConfig,
|
||||
clearError,
|
||||
exitSession,
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,17 @@ export class NumberGuesserValidator
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
|
||||
|
||||
case 'CHOOSE_NUMBER':
|
||||
return this.validateChooseNumber(state, move.data.secretNumber, move.playerId)
|
||||
// Ensure secretNumber is a number (JSON deserialization can make it a string)
|
||||
return this.validateChooseNumber(state, Number(move.data.secretNumber), move.playerId)
|
||||
|
||||
case 'MAKE_GUESS':
|
||||
return this.validateMakeGuess(state, move.data.guess, move.playerId, move.data.playerName)
|
||||
// Ensure guess is a number (JSON deserialization can make it a string)
|
||||
return this.validateMakeGuess(
|
||||
state,
|
||||
Number(move.data.guess),
|
||||
move.playerId,
|
||||
move.data.playerName
|
||||
)
|
||||
|
||||
case 'NEXT_ROUND':
|
||||
return this.validateNextRound(state)
|
||||
@@ -26,7 +33,8 @@ export class NumberGuesserValidator
|
||||
return this.validateGoToSetup(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value)
|
||||
// Ensure value is a number (JSON deserialization can make it a string)
|
||||
return this.validateSetConfig(state, move.data.field, Number(move.data.value))
|
||||
|
||||
default:
|
||||
return {
|
||||
@@ -88,6 +96,12 @@ export class NumberGuesserValidator
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('[NumberGuesser] Setting secret number:', {
|
||||
secretNumber,
|
||||
secretNumberType: typeof secretNumber,
|
||||
})
|
||||
|
||||
// First guesser is the next player after chooser
|
||||
const chooserIndex = state.activePlayers.indexOf(state.chooser)
|
||||
const firstGuesserIndex = (chooserIndex + 1) % state.activePlayers.length
|
||||
@@ -128,7 +142,17 @@ export class NumberGuesserValidator
|
||||
return { valid: false, error: 'No secret number set' }
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('[NumberGuesser] Validating guess:', {
|
||||
guess,
|
||||
guessType: typeof guess,
|
||||
secretNumber: state.secretNumber,
|
||||
secretNumberType: typeof state.secretNumber,
|
||||
})
|
||||
|
||||
const distance = Math.abs(guess - state.secretNumber)
|
||||
|
||||
console.log('[NumberGuesser] Calculated distance:', distance)
|
||||
const newGuess = {
|
||||
playerId,
|
||||
playerName,
|
||||
@@ -181,8 +205,16 @@ export class NumberGuesserValidator
|
||||
}
|
||||
|
||||
private validateNextRound(state: NumberGuesserState): ValidationResult {
|
||||
if (state.gamePhase !== 'guessing' || !state.winner) {
|
||||
return { valid: false, error: 'Cannot start next round yet' }
|
||||
if (state.gamePhase !== 'guessing') {
|
||||
return { valid: false, error: 'Not in guessing phase' }
|
||||
}
|
||||
|
||||
// Check if the round is complete (someone guessed correctly)
|
||||
const roundComplete =
|
||||
state.guesses.length > 0 && state.guesses[state.guesses.length - 1].distance === 0
|
||||
|
||||
if (!roundComplete) {
|
||||
return { valid: false, error: 'Round not complete yet - no one has guessed the number' }
|
||||
}
|
||||
|
||||
// Rotate chooser to next player
|
||||
|
||||
@@ -17,11 +17,21 @@ export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, goToSetup } = useNumberGuesser()
|
||||
|
||||
// Determine whose turn it is based on game phase
|
||||
const currentPlayerId =
|
||||
state.gamePhase === 'choosing'
|
||||
? state.chooser
|
||||
: state.gamePhase === 'guessing'
|
||||
? state.currentGuesser
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Number Guesser"
|
||||
navEmoji="🎯"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
currentPlayerId={currentPlayerId}
|
||||
playerScores={state.scores}
|
||||
onExitSession={() => {
|
||||
exitSession?.()
|
||||
router.push('/arcade')
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function GuessingPhase() {
|
||||
const { state, makeGuess, nextRound } = useNumberGuesser()
|
||||
const { state, makeGuess, nextRound, lastError, clearError } = useNumberGuesser()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
@@ -21,6 +21,14 @@ export function GuessingPhase() {
|
||||
const lastGuess = state.guesses[state.guesses.length - 1]
|
||||
const roundJustEnded = lastGuess?.distance === 0
|
||||
|
||||
// Auto-clear error after 5 seconds
|
||||
useEffect(() => {
|
||||
if (lastError) {
|
||||
const timeout = setTimeout(() => clearError(), 5000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [lastError, clearError])
|
||||
|
||||
const handleSubmit = () => {
|
||||
const guess = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(guess)) return
|
||||
@@ -84,6 +92,81 @@ export function GuessingPhase() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{lastError && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
|
||||
border: '2px solid',
|
||||
borderColor: 'red.300',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 20px',
|
||||
marginBottom: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
animation: 'slideIn 0.3s ease',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
})}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
color: 'red.700',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Move Rejected
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'red.600',
|
||||
})}
|
||||
>
|
||||
{lastError}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
color: 'red.700',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: 'red.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Round ended - show next round button */}
|
||||
{roundJustEnded && (
|
||||
<div
|
||||
|
||||
@@ -26,7 +26,7 @@ interface AddPlayerButtonProps {
|
||||
// Context-aware: show different content based on room state
|
||||
isInRoom?: boolean
|
||||
// Game info for room creation
|
||||
gameName?: 'matching' | 'memory-quiz' | 'complement-race'
|
||||
gameName?: string | null
|
||||
}
|
||||
|
||||
export function AddPlayerButton({
|
||||
@@ -38,7 +38,7 @@ export function AddPlayerButton({
|
||||
activeTab: activeTabProp,
|
||||
setActiveTab: setActiveTabProp,
|
||||
isInRoom = false,
|
||||
gameName = 'Arcade',
|
||||
gameName = null,
|
||||
}: AddPlayerButtonProps) {
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null)
|
||||
const router = useRouter()
|
||||
|
||||
@@ -28,7 +28,7 @@ interface NetworkPlayer {
|
||||
interface ArcadeRoomInfo {
|
||||
roomId?: string
|
||||
roomName?: string
|
||||
gameName: string
|
||||
gameName: string | null
|
||||
playerCount: number
|
||||
joinCode?: string
|
||||
}
|
||||
@@ -200,6 +200,7 @@ export function GameContextNav({
|
||||
onSetup={onSetup}
|
||||
onNewGame={onNewGame}
|
||||
onQuit={onExitSession}
|
||||
showMenu={true}
|
||||
/>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<GameModeIndicator
|
||||
@@ -287,7 +288,7 @@ export function GameContextNav({
|
||||
playerScores={playerScores}
|
||||
playerStreaks={playerStreaks}
|
||||
roomId={roomInfo?.roomId}
|
||||
currentUserId={currentUserId}
|
||||
currentUserId={currentUserId ?? undefined}
|
||||
isCurrentUserHost={isCurrentUserHost}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import type { schema } from '@/db'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useGetRoomByCode, useJoinRoom } from '@/hooks/useRoomData'
|
||||
|
||||
export interface JoinRoomModalProps {
|
||||
/**
|
||||
@@ -25,7 +25,8 @@ export interface JoinRoomModalProps {
|
||||
* Modal for joining a room by entering a 6-character code
|
||||
*/
|
||||
export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps) {
|
||||
const { getRoomByCode, joinRoom } = useRoomData()
|
||||
const { mutateAsync: getRoomByCode } = useGetRoomByCode()
|
||||
const { mutate: joinRoom } = useJoinRoom()
|
||||
const [code, setCode] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
interface RecentRoom {
|
||||
code: string
|
||||
name: string | null
|
||||
gameName: string
|
||||
gameName: string | null
|
||||
joinedAt: number
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function RecentRoomsList({ onSelectRoom }: RecentRoomsListProps) {
|
||||
{getRoomDisplayWithEmoji({
|
||||
name: room.name,
|
||||
code: room.code,
|
||||
gameName: room.gameName,
|
||||
gameName: room.gameName ?? undefined,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@ export function RecentRoomsList({ onSelectRoom }: RecentRoomsListProps) {
|
||||
export function addToRecentRooms(room: {
|
||||
code: string
|
||||
name: string | null
|
||||
gameName: string
|
||||
gameName: string | null
|
||||
}): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
|
||||
@@ -46,6 +46,11 @@ export interface UseArcadeSessionReturn<TState> {
|
||||
*/
|
||||
hasPendingMoves: boolean
|
||||
|
||||
/**
|
||||
* Last error from server (move rejection)
|
||||
*/
|
||||
lastError: string | null
|
||||
|
||||
/**
|
||||
* Send a game move (applies optimistically and sends to server)
|
||||
* Note: playerId must be provided by caller (not omitted)
|
||||
@@ -57,6 +62,11 @@ export interface UseArcadeSessionReturn<TState> {
|
||||
*/
|
||||
exitSession: () => void
|
||||
|
||||
/**
|
||||
* Clear the last error
|
||||
*/
|
||||
clearError: () => void
|
||||
|
||||
/**
|
||||
* Manually sync with server (useful after reconnect)
|
||||
*/
|
||||
@@ -172,8 +182,10 @@ export function useArcadeSession<TState>(
|
||||
version: optimistic.version,
|
||||
connected,
|
||||
hasPendingMoves: optimistic.hasPendingMoves,
|
||||
lastError: optimistic.lastError,
|
||||
sendMove,
|
||||
exitSession,
|
||||
clearError: optimistic.clearError,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ export interface UseOptimisticGameStateReturn<TState> {
|
||||
*/
|
||||
hasPendingMoves: boolean
|
||||
|
||||
/**
|
||||
* Last error from server (move rejection)
|
||||
*/
|
||||
lastError: string | null
|
||||
|
||||
/**
|
||||
* Apply a move optimistically and send to server
|
||||
*/
|
||||
@@ -66,6 +71,11 @@ export interface UseOptimisticGameStateReturn<TState> {
|
||||
*/
|
||||
syncWithServer: (serverState: TState, serverVersion: number) => void
|
||||
|
||||
/**
|
||||
* Clear the last error
|
||||
*/
|
||||
clearError: () => void
|
||||
|
||||
/**
|
||||
* Reset to initial state
|
||||
*/
|
||||
@@ -94,6 +104,9 @@ export function useOptimisticGameState<TState>(
|
||||
// Pending moves that haven't been confirmed by server yet
|
||||
const [pendingMoves, setPendingMoves] = useState<PendingMove<TState>[]>([])
|
||||
|
||||
// Last error from move rejection
|
||||
const [lastError, setLastError] = useState<string | null>(null)
|
||||
|
||||
// Ref for callbacks to avoid stale closures
|
||||
const callbacksRef = useRef({ onMoveAccepted, onMoveRejected })
|
||||
useEffect(() => {
|
||||
@@ -152,6 +165,9 @@ export function useOptimisticGameState<TState>(
|
||||
)
|
||||
|
||||
const handleMoveRejected = useCallback((error: string, rejectedMove: GameMove) => {
|
||||
// Set the error for UI display
|
||||
setLastError(error)
|
||||
|
||||
// Remove the rejected move and all subsequent moves from pending queue
|
||||
setPendingMoves((prev) => {
|
||||
const index = prev.findIndex(
|
||||
@@ -176,20 +192,27 @@ export function useOptimisticGameState<TState>(
|
||||
setPendingMoves([])
|
||||
}, [])
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setLastError(null)
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setServerState(initialState)
|
||||
setServerVersion(1)
|
||||
setPendingMoves([])
|
||||
setLastError(null)
|
||||
}, [initialState])
|
||||
|
||||
return {
|
||||
state: currentState,
|
||||
version: serverVersion,
|
||||
hasPendingMoves: pendingMoves.length > 0,
|
||||
lastError,
|
||||
applyOptimisticMove,
|
||||
handleMoveAccepted,
|
||||
handleMoveRejected,
|
||||
syncWithServer,
|
||||
clearError,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,25 @@
|
||||
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,
|
||||
DEFAULT_MEMORY_QUIZ_CONFIG,
|
||||
DEFAULT_COMPLEMENT_RACE_CONFIG,
|
||||
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
|
||||
@@ -27,6 +34,8 @@ function getDefaultGameConfig(gameName: GameName): GameConfigByName[GameName] {
|
||||
return DEFAULT_MEMORY_QUIZ_CONFIG
|
||||
case 'complement-race':
|
||||
return DEFAULT_COMPLEMENT_RACE_CONFIG
|
||||
case 'number-guesser':
|
||||
return DEFAULT_NUMBER_GUESSER_CONFIG
|
||||
default:
|
||||
throw new Error(`Unknown game: ${gameName}`)
|
||||
}
|
||||
@@ -36,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]> {
|
||||
@@ -62,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]>
|
||||
@@ -110,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
|
||||
@@ -123,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(
|
||||
@@ -139,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
|
||||
@@ -165,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 (
|
||||
@@ -194,6 +203,18 @@ export function validateGameConfig(gameName: GameName, config: any): boolean {
|
||||
// TODO: Add validation when complement-race settings are defined
|
||||
return typeof config === 'object' && config !== null
|
||||
|
||||
case 'number-guesser':
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
typeof config.minNumber === 'number' &&
|
||||
typeof config.maxNumber === 'number' &&
|
||||
typeof config.roundsToWin === 'number' &&
|
||||
config.minNumber >= 1 &&
|
||||
config.maxNumber > config.minNumber &&
|
||||
config.roundsToWin >= 1
|
||||
)
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,10 +19,24 @@
|
||||
"src/db/schema/**/*.ts",
|
||||
"src/db/migrate.ts",
|
||||
"src/lib/arcade/**/*.ts",
|
||||
"src/arcade-games/**/Validator.ts",
|
||||
"src/arcade-games/**/types.ts",
|
||||
"src/app/games/matching/context/types.ts",
|
||||
"src/app/games/matching/utils/cardGeneration.ts",
|
||||
"src/app/games/matching/utils/matchValidation.ts",
|
||||
"src/app/arcade/memory-quiz/types.ts",
|
||||
"src/socket-server.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts"]
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"src/components/**/*",
|
||||
"src/contexts/**/*",
|
||||
"src/hooks/**/*",
|
||||
"src/stories/**/*",
|
||||
"src/utils/**/*",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.22.0",
|
||||
"version": "3.22.3",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user