Compare commits

...

16 Commits

Author SHA1 Message Date
semantic-release-bot
d1c40f1733 chore(release): 4.1.0 [skip ci]
## [4.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.3...v4.1.0) (2025-10-16)

### Features

* **arcade:** migrate memory-quiz to modular game system ([f48c37a](f48c37accc))

### Code Refactoring

* **arcade:** remove memory-quiz from legacy GAMES_CONFIG ([9952e11](9952e11c27))

### Documentation

* add matching pairs battle migration plan ([3948582](39485826fc))
* add memory-quiz migration plan documentation ([7e2df10](7e2df106e6))
* **arcade:** document Phase 3 completion in ARCHITECTURAL_IMPROVEMENTS.md ([704f34f](704f34f83e))
* update playbook with memory-quiz completion ([99eee69](99eee69f28))
2025-10-16 03:34:36 +00:00
Thomas Hallock
39485826fc docs: add matching pairs battle migration plan
Create comprehensive migration plan for Matching Pairs Battle game.
Documents dual-location complexity, 8-phase migration approach, and
key differences from Memory Quiz migration.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 22:33:39 -05:00
Thomas Hallock
7e2df106e6 docs: add memory-quiz migration plan documentation
Add detailed migration plan document for the Memory Quiz game migration
to the modular game system. This serves as a reference for future game
migrations.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 22:31:35 -05:00
Thomas Hallock
99eee69f28 docs: update playbook with memory-quiz completion
Mark memory-quiz migration as completed in the Game Migration Playbook.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 22:31:35 -05:00
Thomas Hallock
9952e11c27 refactor(arcade): remove memory-quiz from legacy GAMES_CONFIG
Memory-quiz now only exists in the game registry, eliminating duplicate:
- Removed from GAMES_CONFIG in GameSelector.tsx
- Removed from GAME_TYPE_TO_NAME mapping in room/page.tsx
- Updated /arcade/memory-quiz route to redirect to arcade
- Removed legacy switch case (now handled by registry)

Fixes issue where Memory Lightning appeared twice in game selector.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 22:28:45 -05:00
Thomas Hallock
f48c37accc feat(arcade): migrate memory-quiz to modular game system
Create new modular structure for Memory Lightning game:
- New location: /src/arcade-games/memory-quiz/
- Game definition with manifest and config validation
- Unified Provider using useArcadeSession (room-mode only)
- Server-side Validator for move validation
- SDK-compatible types (Config, State, Moves)
- Registered in game-registry.ts

Key changes:
- Room-mode only (local mode deprecated)
- Type-safe config with InferGameConfig<>
- Action creators replace reducer pattern
- Optimistic client updates + server validation
- Config persistence to room_game_configs table

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 22:28:28 -05:00
Thomas Hallock
704f34f83e docs(arcade): document Phase 3 completion in ARCHITECTURAL_IMPROVEMENTS.md
**Updates**:
- Added Phase 3 section with implementation details
- Updated "Before vs After" comparison: 12 files → 3 files (75% reduction)
- Updated Executive Summary: Grade A (up from B- originally, A- after Phase 2)
- Updated Conclusion with all three phases completed
- Updated Quick Reference with Phase 3 type inference steps
- Renamed "Future Work" to include optional Phase 4

**Key Metrics**:
- Files to update: 12 → 3 (75% reduction)
- Lines of boilerplate: ~60 → ~20 (67% reduction)
- All critical architectural issues resolved

**Status**: Production-ready modular game system.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 21:40:39 -05:00
semantic-release-bot
9e393b42aa chore(release): 4.0.3 [skip ci]
## [4.0.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.2...v4.0.3) (2025-10-16)

### Bug Fixes

* **math-sprint:** remove unused import and autoFocus attribute ([51593eb](51593eb44f))

### Code Refactoring

* **arcade:** implement Phase 3 - infer config types from game definitions ([eed468c](eed468c6c4))

### Documentation

* **arcade:** update README with Phase 3 type inference architecture ([b47b1cc](b47b1cc03f))

### Styles

* **math-sprint:** apply Biome formatting ([d7d8d8b](d7d8d8b1e3))
2025-10-16 02:39:45 +00:00
Thomas Hallock
d7d8d8b1e3 style(math-sprint): apply Biome formatting
**Changes**: Auto-formatting from Biome formatter.
- Provider: Multi-line formatting for useArcadeSession call
- SetupPhase: Multi-line formatting for long className

No logic changes, purely stylistic.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 21:38:44 -05:00
Thomas Hallock
51593eb44f fix(math-sprint): remove unused import and autoFocus attribute
**Lint fixes**:
- Removed unused TEAM_MOVE import from Validator.ts
- Removed autoFocus attribute from PlayingPhase input (a11y best practice)

**Reason**: These were flagged by Biome linter as issues.
The unused import was left over from development, and autoFocus
can cause accessibility problems.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 21:38:44 -05:00
Thomas Hallock
b47b1cc03f docs(arcade): update README with Phase 3 type inference architecture
**Updates**:
- Added "Key Improvements" section highlighting Phase 3
- Updated architecture diagram to show type system layer
- Added validateConfig to GameDefinition interface docs
- Updated Step 6 to include validateConfig example
- Added Step 7c: Config Type Inference guide
- Documented benefits of type inference (10-15 lines saved per game)

**Example shown**:
```typescript
// Before: Manual definition
export interface NumberGuesserGameConfig { ... }

// After: Inferred
export type NumberGuesserGameConfig = InferGameConfig<typeof numberGuesserGame>
```

**Key concept**: defaultConfig serves as source of truth for types.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 21:38:44 -05:00
Thomas Hallock
eed468c6c4 refactor(arcade): implement Phase 3 - infer config types from game definitions
**Problem**: Config types were manually defined in game-configs.ts,
requiring 10-15 lines of boilerplate per game.

**Solution**: Use TypeScript's type inference to extract config types
from game definitions' defaultConfig property.

**Changes**:
- Added InferGameConfig<T> utility type
- NumberGuesserGameConfig now inferred from numberGuesserGame
- MathSprintGameConfig now inferred from mathSprintGame
- RoomGameConfig auto-derived from GameConfigByName using mapped type
- Changed RoomGameConfig from interface to type for auto-derivation

**Benefits**:
- Single source of truth (game definition)
- Add game → types automatically available
- No manual type definitions needed
- TypeScript ensures type consistency

**Architecture**: Phase 3 of modular game system improvements.
Legacy games (matching, memory-quiz, complement-race) still use
manual types until migrated to new system.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 21:38:44 -05:00
semantic-release-bot
d17ebb3f42 chore(release): 4.0.2 [skip ci]
## [4.0.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.1...v4.0.2) (2025-10-16)

### Bug Fixes

* **arcade:** prevent server-side loading of React components ([784793b](784793ba24))
2025-10-16 02:28:55 +00:00
Thomas Hallock
784793ba24 fix(arcade): prevent server-side loading of React components
Issue: game-config-helpers.ts was importing game-registry.ts which loads
game definitions including React components. This caused server startup to
fail with MODULE_NOT_FOUND for GameModeContext.

Solution: Lazy-load game registry only in browser environment.
On server, getGame() returns undefined and validation falls back to
switch statement for legacy games.

Changes:
- game-config-helpers.ts: Add conditional getGame() that checks typeof window
- Only requires game-registry in browser environment
- Server uses switch statement fallback for validation
- Browser uses game.validateConfig() when available

This maintains the architectural improvement (games own validation)
while keeping server-side code working.

Test: Dev server starts successfully, no MODULE_NOT_FOUND errors
2025-10-15 21:27:59 -05:00
semantic-release-bot
aa868e3f7f chore(release): 4.0.1 [skip ci]
## [4.0.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.0...v4.0.1) (2025-10-16)

### Code Refactoring

* **arcade:** move config validation to game definitions ([b19437b](b19437b7dc)), closes [#3](https://github.com/antialias/soroban-abacus-flashcards/issues/3)
2025-10-16 02:20:55 +00:00
Thomas Hallock
b19437b7dc refactor(arcade): move config validation to game definitions
This implements Critical Fix #3 from AUDIT_2_ARCHITECTURE_QUALITY.md

Changes:
1. Add validateConfig to GameDefinition type
2. Update defineGame() to accept validateConfig function
3. Add validation functions to Number Guesser and Math Sprint
4. Update game-config-helpers.ts to use registry validation

Before (switch statement in helpers):
  - validateGameConfig() had 50+ line switch statement
  - Must update helper for every new game
  - Validation logic separated from game

After (validation in game definition):
  - Games own their validation logic
  - validateGameConfig() calls game.validateConfig()
  - Switch only for legacy games (matching, memory-quiz, complement-race)
  - New games: just add validateConfig to defineGame()

Example (Number Guesser):
  function validateNumberGuesserConfig(config: unknown): config is NumberGuesserConfig {
    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
    )
  }

Benefits:
 Eliminates switch statement boilerplate
 Single source of truth for validation
 Games are self-contained
 No helper updates needed for new games

To add a new game now:
1. Define validation function in game index.ts
2. Pass to defineGame({ validateConfig })
That's it! No helper file changes needed.
2025-10-15 21:20:11 -05:00
38 changed files with 3306 additions and 769 deletions

View File

@@ -1,3 +1,59 @@
## [4.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.3...v4.1.0) (2025-10-16)
### Features
* **arcade:** migrate memory-quiz to modular game system ([f48c37a](https://github.com/antialias/soroban-abacus-flashcards/commit/f48c37accccb88e790c7a1b438fd0566e7120e11))
### Code Refactoring
* **arcade:** remove memory-quiz from legacy GAMES_CONFIG ([9952e11](https://github.com/antialias/soroban-abacus-flashcards/commit/9952e11c27f6cacb8eef1c5494b8cfea29dac907))
### Documentation
* add matching pairs battle migration plan ([3948582](https://github.com/antialias/soroban-abacus-flashcards/commit/39485826fc6c87f54c07795211909da0278a2ad0))
* add memory-quiz migration plan documentation ([7e2df10](https://github.com/antialias/soroban-abacus-flashcards/commit/7e2df106e68a1a0be414852a3e603b89029635b7))
* **arcade:** document Phase 3 completion in ARCHITECTURAL_IMPROVEMENTS.md ([704f34f](https://github.com/antialias/soroban-abacus-flashcards/commit/704f34f83e76332cb3610bda75289cbd0036e7eb))
* update playbook with memory-quiz completion ([99eee69](https://github.com/antialias/soroban-abacus-flashcards/commit/99eee69f28d17d0f9a3c806a1b84d90ee1fad683))
## [4.0.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.2...v4.0.3) (2025-10-16)
### Bug Fixes
* **math-sprint:** remove unused import and autoFocus attribute ([51593eb](https://github.com/antialias/soroban-abacus-flashcards/commit/51593eb44f93e369d6a773ee80e5f5cf50f3be67))
### Code Refactoring
* **arcade:** implement Phase 3 - infer config types from game definitions ([eed468c](https://github.com/antialias/soroban-abacus-flashcards/commit/eed468c6c4057e3c09a1e8df88551a9336c490c5))
### Documentation
* **arcade:** update README with Phase 3 type inference architecture ([b47b1cc](https://github.com/antialias/soroban-abacus-flashcards/commit/b47b1cc03f4b5fcfe8340653ca8a5dd903833481))
### Styles
* **math-sprint:** apply Biome formatting ([d7d8d8b](https://github.com/antialias/soroban-abacus-flashcards/commit/d7d8d8b1e32f9c9bb73d076f5d611210f809eca8))
## [4.0.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.1...v4.0.2) (2025-10-16)
### Bug Fixes
* **arcade:** prevent server-side loading of React components ([784793b](https://github.com/antialias/soroban-abacus-flashcards/commit/784793ba244731edf45391da44588a978b137abe))
## [4.0.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.0...v4.0.1) (2025-10-16)
### Code Refactoring
* **arcade:** move config validation to game definitions ([b19437b](https://github.com/antialias/soroban-abacus-flashcards/commit/b19437b7dc418f194fb60e12f1c17034024eca2a)), closes [#3](https://github.com/antialias/soroban-abacus-flashcards/issues/3)
## [4.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.24.0...v4.0.0) (2025-10-16)

View File

@@ -78,7 +78,8 @@
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run ID: \\(.databaseId)\"\"')",
"Bash(tsc:*)",
"Bash(tsc-alias:*)",
"Bash(npx tsc-alias:*)"
"Bash(npx tsc-alias:*)",
"Bash(timeout 20 pnpm run:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,302 @@
# Architectural Improvements - Summary
**Date**: 2025-10-16
**Status**: ✅ **Implemented**
**Based on**: AUDIT_2_ARCHITECTURE_QUALITY.md
---
## Executive Summary
Successfully implemented **all 3 critical architectural improvements** identified in the audit. The modular game system is now **truly modular** - new games can be added without touching database schemas, API endpoints, helper switch statements, or manual type definitions.
**Phase 1**: Eliminated database schema coupling
**Phase 2**: Moved config validation to game definitions
**Phase 3**: Implemented type inference from game definitions
**Grade**: **A** (Up from B- after improvements)
---
## What Was Fixed
### 1. ✅ Database Schema Coupling (CRITICAL)
**Problem**: Schemas used hardcoded enums, requiring migration for each new game.
**Solution**: Accept any string, validate at runtime against validator registry.
**Changes**:
- `arcade-rooms.ts`: `gameName: text('game_name')` (removed enum)
- `arcade-sessions.ts`: `currentGame: text('current_game').notNull()` (removed enum)
- `room-game-configs.ts`: `gameName: text('game_name').notNull()` (removed enum)
- Added `isValidGameName()` and `assertValidGameName()` runtime validators
- Updated settings API to use `isValidGameName()` instead of hardcoded array
**Impact**:
```diff
- BEFORE: Update 3 database schemas + run migration for each game
+ AFTER: No database changes needed - just register validator
```
**Files Modified**: 4 files
**Commit**: `e135d92a - refactor(db): remove database schema coupling for game names`
---
### 2. ✅ Config Validation in Game Definitions
**Problem**: 50+ line switch statement in `game-config-helpers.ts` had to be updated for each game.
**Solution**: Move validation to game definitions - games own their validation logic.
**Changes**:
- Added `validateConfig?: (config: unknown) => config is TConfig` to `GameDefinition`
- Updated `defineGame()` to accept and return `validateConfig`
- Added validation to Number Guesser and Math Sprint
- Updated `validateGameConfig()` to call `game.validateConfig()` from registry
**Impact**:
```diff
- BEFORE: Add case to 50-line switch statement in helper file
+ AFTER: Add validateConfig function to game definition
```
**Example**:
```typescript
// In game index.ts
function validateMathSprintConfig(config: unknown): config is MathSprintConfig {
return (
typeof config === 'object' &&
config !== null &&
['easy', 'medium', 'hard'].includes(config.difficulty) &&
typeof config.questionsPerRound === 'number' &&
config.questionsPerRound >= 5 &&
config.questionsPerRound <= 20
)
}
export const mathSprintGame = defineGame({
// ... other fields
validateConfig: validateMathSprintConfig,
})
```
**Files Modified**: 5 files
**Commit**: `b19437b7 - refactor(arcade): move config validation to game definitions`
---
## Before vs After Comparison
### Adding a New Game
| Task | Before | After (Phase 1-3) |
|------|--------|----------|
| **Database Schemas** | Update 3 enum types | ✅ No changes needed |
| **Settings API** | Add to validGames array | ✅ No changes needed (runtime validation) |
| **Config Helpers** | Add switch case + validation (25 lines) | ✅ No changes needed |
| **Game Config Types** | Manually define interface (10-15 lines) | ✅ One-line type inference |
| **GameConfigByName** | Add entry manually | ✅ Add entry (auto-typed) |
| **RoomGameConfig** | Add optional property | ✅ Auto-derived from GameConfigByName |
| **Default Config** | Add to DEFAULT_X_CONFIG constant | ✔️ Still needed (3-5 lines) |
| **Validator Registry** | Register in validators.ts | ✔️ Still needed (1 line) |
| **Game Registry** | Register in game-registry.ts | ✔️ Still needed (1 line) |
| **validateConfig Function** | N/A | ✔️ Add to game definition (10-15 lines) |
**Total Files to Update**: 12 → **3** (75% reduction)
**Total Lines of Boilerplate**: ~60 lines → ~20 lines (67% reduction)
### What's Left
Three items still require manual updates:
1. **Default Config Constants** (`game-configs.ts`) - 3-5 lines per game
2. **Validator Registry** (`validators.ts`) - 1 line per game
3. **Game Registry** (`game-registry.ts`) - 1 line per game
4. **validateConfig Function** (in game definition) - 10-15 lines per game (but co-located with game!)
---
## Migration Impact
### Existing Data
-**No data migration needed** - strings remain strings
-**Backward compatible** - existing games work unchanged
### TypeScript Changes
- ⚠️ Database columns now accept `string` instead of specific enum
- ✅ Runtime validation prevents invalid data
- ✅ Type safety maintained through validator registry
### Developer Experience
```diff
- BEFORE: 15-20 minutes of boilerplate per game
+ AFTER: 2-3 minutes to add validation function
```
---
## Architectural Wins
### 1. Single Source of Truth
- ✅ Validator registry is the authoritative list of games
- ✅ All validation checks against registry at runtime
- ✅ No duplication across database/API/helpers
### 2. Self-Contained Games
- ✅ Games define their own validation logic
- ✅ No scattered switch statements
- ✅ Easy to understand - everything in one place
### 3. True Modularity
- ✅ Database schemas accept any registered game
- ✅ API endpoints dynamically validate
- ✅ Helper functions delegate to games
### 4. Developer Friction Reduced
- ✅ No database schema changes
- ✅ No API endpoint updates
- ✅ No helper switch statements
- ✅ Clear error messages (runtime validation)
---
### 3. ✅ Config Type Inference (Phase 3)
**Problem**: Config types manually defined in `game-configs.ts`, requiring 10-15 lines per game.
**Solution**: Use TypeScript utility types to infer from game definitions.
**Changes**:
- Added `InferGameConfig<T>` utility type that extracts config from game definitions
- `NumberGuesserGameConfig` now inferred: `InferGameConfig<typeof numberGuesserGame>`
- `MathSprintGameConfig` now inferred: `InferGameConfig<typeof mathSprintGame>`
- `RoomGameConfig` auto-derived from `GameConfigByName` using mapped types
- Changed `RoomGameConfig` from interface to type for auto-derivation
**Impact**:
```diff
- BEFORE: Manually define interface with 10-15 lines per game
+ AFTER: One-line type inference from game definition
```
**Example**:
```typescript
// Type-only import (won't load React components)
import type { mathSprintGame } from '@/arcade-games/math-sprint'
// Utility type
type InferGameConfig<T> = T extends { defaultConfig: infer Config } ? Config : never
// Inferred type (was 6 lines, now 1 line!)
export type MathSprintGameConfig = InferGameConfig<typeof mathSprintGame>
// Auto-derived RoomGameConfig (was 5 manual entries, now automatic!)
export type RoomGameConfig = {
[K in keyof GameConfigByName]?: GameConfigByName[K]
}
```
**Files Modified**: 2 files
**Commits**:
- `271b8ec3 - refactor(arcade): implement Phase 3 - infer config types from game definitions`
- `4c15c13f - docs(arcade): update README with Phase 3 type inference architecture`
**Note**: Default config constants (e.g., `DEFAULT_MATH_SPRINT_CONFIG`) still manually defined. This small duplication is necessary for server-side code that can't import full game definitions with React components.
---
## Future Work (Optional)
### Phase 4: Extract Config-Only Exports
**Optional improvement**: Create separate `config.ts` files in each game directory that export just config and validation (no React dependencies). This would allow importing default configs directly without duplication.
---
## Testing
### Manual Testing
- ✅ Math Sprint works end-to-end
- ✅ Number Guesser works end-to-end
- ✅ Room settings API accepts math-sprint
- ✅ Config validation rejects invalid configs
- ✅ TypeScript compilation succeeds
### Test Coverage Needed
- [ ] Unit tests for `isValidGameName()`
- [ ] Unit tests for game `validateConfig()` functions
- [ ] Integration test: Add new game without touching infrastructure
- [ ] E2E test: Verify runtime validation works
---
## Lessons Learned
### What Worked Well
1. **Incremental Approach** - Fixed one issue at a time
2. **Backward Compatibility** - Legacy games still work
3. **Runtime Validation** - Flexible and extensible
4. **Clear Commit Messages** - Easy to track changes
### Challenges
1. **TypeScript Enums → Runtime Checks** - Required migration strategy
2. **Fallback for Legacy Games** - Switch statement still exists for old games
3. **Type Inference** - Config types still manually defined
### Best Practices Established
1. **Games own validation** - Self-contained, testable
2. **Registry as source of truth** - No duplicate lists
3. **Runtime validation** - Catch errors early with good messages
4. **Fail-fast** - Use assertions where appropriate
---
## Conclusion
The modular game system is now **significantly improved across all three phases**:
**Before (Phases 1-3)**:
- Must update 12 files to add a game (~60 lines of boilerplate)
- Database migration required for each new game
- Easy to forget a step (manual type definitions, switch statements)
- Scattered validation logic across multiple files
**After (All Phases Complete)**:
- Update 3 files to add a game (75% reduction)
- ~20 lines of boilerplate (67% reduction)
- No database migration needed
- Validation is self-contained in game definitions
- Config types auto-inferred from game definitions
- Clear runtime error messages
**Key Achievements**:
1.**Phase 1**: Runtime validation replaces database enums
2.**Phase 2**: Games own their validation logic
3.**Phase 3**: TypeScript types inferred from game definitions
**Remaining Work**:
- Optional Phase 4: Extract config-only exports to eliminate DEFAULT_*_CONFIG duplication
- Add comprehensive test suite for validation and type inference
- Migrate legacy games (matching, memory-quiz) to new system
The architecture is now **production-ready** and can scale to dozens of games without becoming unmaintainable. Each game is truly self-contained, with all its logic, validation, and types defined in one place.
---
## Quick Reference: Adding a New Game
1. Create game directory with required files (types, Validator, Provider, components, index)
2. Add validation function (`validateConfig`) in index.ts and pass to `defineGame()`
3. Register validator in `validators.ts` (1 line)
4. Register game in `game-registry.ts` (1 line)
5. Add type inference to `game-configs.ts`:
```typescript
import type { myGame } from '@/arcade-games/my-game'
export type MyGameConfig = InferGameConfig<typeof myGame>
```
6. Add to `GameConfigByName` (1 line - type is auto-inferred!)
7. Add defaults to `game-configs.ts` (3-5 lines)
**That's it!** No database schemas, API endpoints, helper switch statements, or manual interface definitions.
**Total**: 3 files to update, ~20 lines of boilerplate

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,502 @@
# Matching Pairs Battle - Migration to Modular Game System
**Status**: Planning Phase
**Target Version**: v4.2.0
**Created**: 2025-01-16
**Game Name**: `matching`
---
## Executive Summary
This document outlines the migration plan for **Matching Pairs Battle** (aka Memory Pairs Challenge) from the legacy dual-location architecture to the modern modular game system using the Game SDK.
**Key Complexity Factors**:
- **Dual Location**: Game exists in BOTH `/src/app/arcade/matching/` AND `/src/app/games/matching/`
- **Partial Migration**: RoomMemoryPairsProvider already uses `useArcadeSession` but not in modular format
- **Turn-Based Multiplayer**: More complex than memory-quiz (requires turn validation, player ownership)
- **Rich UI State**: Hover state, animations, mismatch feedback, pause/resume
- **Existing Tests**: Has playerMetadata test that must continue to pass
---
## Current File Structure Analysis
### Location 1: `/src/app/arcade/matching/`
**Components** (4 files):
- `components/GameCard.tsx`
- `components/PlayerStatusBar.tsx`
- `components/ResultsPhase.tsx`
- `components/SetupPhase.tsx`
- `components/EmojiPicker.tsx`
- `components/GamePhase.tsx`
- `components/MemoryPairsGame.tsx`
- `components/__tests__/EmojiPicker.test.tsx`
**Context** (4 files):
- `context/MemoryPairsContext.tsx` - Context definition and hook
- `context/LocalMemoryPairsProvider.tsx` - Local mode provider (DEPRECATED)
- `context/RoomMemoryPairsProvider.tsx` - Room mode provider (PARTIALLY MIGRATED)
- `context/types.ts` - Type definitions
- `context/index.ts` - Re-exports
- `context/__tests__/playerMetadata-userId.test.ts` - Test for player ownership
**Utils** (3 files):
- `utils/cardGeneration.ts` - Card generation logic
- `utils/gameScoring.ts` - Scoring calculations
- `utils/matchValidation.ts` - Match validation logic
**Page**:
- `page.tsx` - Route handler for `/arcade/matching`
### Location 2: `/src/app/games/matching/`
**Components** (6 files - DUPLICATES):
- `components/GameCard.tsx`
- `components/PlayerStatusBar.tsx`
- `components/ResultsPhase.tsx`
- `components/SetupPhase.tsx`
- `components/EmojiPicker.tsx`
- `components/GamePhase.tsx`
- `components/MemoryPairsGame.tsx`
- `components/__tests__/EmojiPicker.test.tsx`
- `components/PlayerStatusBar.stories.tsx` - Storybook story
**Context** (2 files):
- `context/MemoryPairsContext.tsx`
- `context/types.ts`
**Utils** (3 files - DUPLICATES):
- `utils/cardGeneration.ts`
- `utils/gameScoring.ts`
- `utils/matchValidation.ts`
**Page**:
- `page.tsx` - Route handler for `/games/matching` (legacy?)
### Shared Components
- `/src/components/matching/HoverAvatar.tsx` - Networked presence component
- `/src/components/matching/MemoryGrid.tsx` - Grid layout component
### Validator
- `/src/lib/arcade/validation/MatchingGameValidator.ts` - ✅ Already exists and comprehensive (570 lines)
### Configuration
- Already in `GAMES_CONFIG` as `'battle-arena'` (maps to internal name `'matching'`)
- Config type: `MatchingGameConfig` in `/src/lib/arcade/game-configs.ts`
---
## Migration Complexity Assessment
### Complexity: **HIGH** (8/10)
**Reasons**:
1. **Dual Locations**: Must consolidate two separate implementations
2. **Partial Migration**: RoomMemoryPairsProvider uses useArcadeSession but not in modular format
3. **Turn-Based Logic**: Player ownership validation, turn switching
4. **Rich State**: Hover state, animations, pause/resume, mismatch feedback
5. **Large Validator**: 570 lines (vs 350 for memory-quiz)
6. **More Components**: 7 components + 2 shared (vs 7 for memory-quiz)
7. **Tests**: Must maintain playerMetadata test coverage
**Similar To**: Memory Quiz migration (same pattern)
**Unique Challenges**:
- Consolidating duplicate files from two locations
- Deciding which version of duplicates is canonical
- Handling `/games/matching/` route (deprecate or redirect?)
- More complex multiplayer state (turn order, player ownership)
---
## Recommended Migration Approach
### Phase 1: Pre-Migration Audit ✅
**Goal**: Understand current state and identify discrepancies
**Tasks**:
- [x] Map all files in both locations
- [ ] Compare duplicate files to identify differences (e.g., `diff /src/app/arcade/matching/components/GameCard.tsx /src/app/games/matching/components/GameCard.tsx`)
- [ ] Identify which location is canonical (likely `/src/app/arcade/matching/` based on RoomProvider)
- [ ] Verify validator completeness (already done - looks comprehensive)
- [ ] Check for references to `/games/matching/` route
**Deliverables**:
- File comparison report
- Decision: Which duplicate files to keep
- List of files to delete
---
### Phase 2: Create Modular Game Definition
**Goal**: Define game in registry following SDK pattern
**Tasks**:
1. Create `/src/arcade-games/matching/index.ts` with `defineGame()`
2. Register in `/src/lib/arcade/game-registry.ts`
3. Update `/src/lib/arcade/validators.ts` to import from new location
4. Add type inference to `/src/lib/arcade/game-configs.ts`
**Template**:
```typescript
// /src/arcade-games/matching/index.ts
import type { GameManifest, GameConfig } from '@/lib/arcade/game-sdk/types'
import { defineGame } from '@/lib/arcade/game-sdk'
import { MatchingProvider } from './Provider'
import { MemoryPairsGame } from './components/MemoryPairsGame'
import { matchingGameValidator } from './Validator'
import { validateMatchingConfig } from './config-validation'
import type { MatchingConfig, MatchingState, MatchingMove } from './types'
const manifest: GameManifest = {
name: 'matching',
displayName: 'Matching Pairs Battle',
icon: '⚔️',
description: 'Multiplayer memory battle with friends',
longDescription: 'Battle friends in epic memory challenges. Match pairs faster than your opponents in this exciting multiplayer experience.',
maxPlayers: 4,
difficulty: 'Intermediate',
chips: ['👥 Multiplayer', '🎯 Strategic', '🏆 Competitive'],
color: 'purple',
gradient: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)',
borderColor: 'purple.200',
available: true,
}
const defaultConfig: MatchingConfig = {
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
}
export const matchingGame = defineGame<MatchingConfig, MatchingState, MatchingMove>({
manifest,
Provider: MatchingProvider,
GameComponent: MemoryPairsGame,
validator: matchingGameValidator,
defaultConfig,
validateConfig: validateMatchingConfig,
})
```
**Files Modified**:
- `/src/arcade-games/matching/index.ts` (new)
- `/src/lib/arcade/game-registry.ts` (add import + register)
- `/src/lib/arcade/validators.ts` (update import path)
- `/src/lib/arcade/game-configs.ts` (add type inference)
---
### Phase 3: Move and Update Validator
**Goal**: Move validator to modular game directory
**Tasks**:
1. Move `/src/lib/arcade/validation/MatchingGameValidator.ts``/src/arcade-games/matching/Validator.ts`
2. Update imports to use local types from `./types` instead of importing from game-configs (avoid circular deps)
3. Verify all move types are handled
4. Check `getInitialState()` accepts all config fields
**Note**: Validator looks comprehensive already - likely minimal changes needed
**Files Modified**:
- `/src/arcade-games/matching/Validator.ts` (moved)
- Update imports in validator
---
### Phase 4: Consolidate and Move Types
**Goal**: Create SDK-compatible type definitions in modular location
**Tasks**:
1. Compare types from both locations:
- `/src/app/arcade/matching/context/types.ts`
- `/src/app/games/matching/context/types.ts`
2. Create `/src/arcade-games/matching/types.ts` with:
- `MatchingConfig extends GameConfig`
- `MatchingState` (from MemoryPairsState)
- `MatchingMove` union type (7 move types: FLIP_CARD, START_GAME, CLEAR_MISMATCH, GO_TO_SETUP, SET_CONFIG, RESUME_GAME, HOVER_CARD)
3. Ensure compatibility with validator expectations
4. Fix any `{}``Record<string, never>` warnings
**Move Types**:
```typescript
export interface MatchingConfig extends GameConfig {
gameType: 'abacus-numeral' | 'complement-pairs'
difficulty: 6 | 8 | 12 | 15
turnTimer: number
}
export interface MatchingState {
// Core game data
cards: GameCard[]
gameCards: GameCard[]
flippedCards: GameCard[]
// Config
gameType: 'abacus-numeral' | 'complement-pairs'
difficulty: 6 | 8 | 12 | 15
turnTimer: number
// Progression
gamePhase: 'setup' | 'playing' | 'results'
currentPlayer: string
matchedPairs: number
totalPairs: number
moves: number
scores: Record<string, number>
activePlayers: string[]
playerMetadata: Record<string, PlayerMetadata>
consecutiveMatches: Record<string, number>
// Timing
gameStartTime: number | null
gameEndTime: number | null
currentMoveStartTime: number | null
timerInterval: NodeJS.Timeout | null
// UI state
celebrationAnimations: CelebrationAnimation[]
isProcessingMove: boolean
showMismatchFeedback: boolean
lastMatchedPair: [string, string] | null
// Pause/Resume
originalConfig?: {
gameType: 'abacus-numeral' | 'complement-pairs'
difficulty: 6 | 8 | 12 | 15
turnTimer: number
}
pausedGamePhase?: 'setup' | 'playing' | 'results'
pausedGameState?: PausedGameState
// Hover state
playerHovers: Record<string, string | null>
}
export type MatchingMove =
| { type: 'FLIP_CARD'; playerId: string; userId: string; data: { cardId: string } }
| { type: 'START_GAME'; playerId: string; userId: string; data: { cards: GameCard[]; activePlayers: string[]; playerMetadata: Record<string, PlayerMetadata> } }
| { type: 'CLEAR_MISMATCH'; playerId: string; userId: string; data: Record<string, never> }
| { type: 'GO_TO_SETUP'; playerId: string; userId: string; data: Record<string, never> }
| { type: 'SET_CONFIG'; playerId: string; userId: string; data: { field: 'gameType' | 'difficulty' | 'turnTimer'; value: any } }
| { type: 'RESUME_GAME'; playerId: string; userId: string; data: Record<string, never> }
| { type: 'HOVER_CARD'; playerId: string; userId: string; data: { cardId: string | null } }
```
**Files Created**:
- `/src/arcade-games/matching/types.ts`
---
### Phase 5: Create Unified Provider
**Goal**: Convert RoomMemoryPairsProvider to modular Provider using SDK
**Tasks**:
1. Copy RoomMemoryPairsProvider as starting point (already uses useArcadeSession)
2. Create `/src/arcade-games/matching/Provider.tsx`
3. Remove dependency on MemoryPairsContext (will export its own hook)
4. Update imports to use local types
5. Ensure all action creators are present:
- `startGame`
- `flipCard`
- `resetGame`
- `setGameType`
- `setDifficulty`
- `setTurnTimer`
- `goToSetup`
- `resumeGame`
- `hoverCard`
6. Verify config persistence (nested under `gameConfig.matching`)
7. Export `useMatching` hook
**Key Changes**:
- Import types from `./types` not from context
- Export hook: `export function useMatching() { return useContext(MatchingContext) }`
- Ensure hooks called before early returns (React rules)
**Files Created**:
- `/src/arcade-games/matching/Provider.tsx`
---
### Phase 6: Consolidate and Move Components
**Goal**: Move components to modular location, choosing canonical versions
**Decision Process** (for each component):
1. If files are identical → pick either (prefer `/src/app/arcade/matching/`)
2. If files differ → manually merge, keeping best of both
3. Update imports to use new Provider: `from '@/arcade-games/matching/Provider'`
4. Fix styled-system import paths (4 levels: `../../../../styled-system/css`)
**Components to Move**:
- GameCard.tsx
- PlayerStatusBar.tsx
- ResultsPhase.tsx
- SetupPhase.tsx
- EmojiPicker.tsx
- GamePhase.tsx
- MemoryPairsGame.tsx
**Shared Components** (leave in place):
- `/src/components/matching/HoverAvatar.tsx`
- `/src/components/matching/MemoryGrid.tsx`
**Tests**:
- Move test to `/src/arcade-games/matching/components/__tests__/EmojiPicker.test.tsx`
**Files Created**:
- `/src/arcade-games/matching/components/*.tsx` (7 files)
- `/src/arcade-games/matching/components/__tests__/EmojiPicker.test.tsx`
---
### Phase 7: Move Utility Functions
**Goal**: Consolidate utils in modular location
**Tasks**:
1. Compare utils from both locations (likely identical)
2. Move to `/src/arcade-games/matching/utils/`
- `cardGeneration.ts`
- `gameScoring.ts`
- `matchValidation.ts`
3. Update imports in components and validator
**Files Created**:
- `/src/arcade-games/matching/utils/*.ts` (3 files)
---
### Phase 8: Update Routes and Clean Up
**Goal**: Update page routes and delete legacy files
**Tasks**:
**Route Updates**:
1. `/src/app/arcade/matching/page.tsx` - Replace with redirect to `/arcade` (local mode deprecated)
2. `/src/app/games/matching/page.tsx` - Replace with redirect to `/arcade` (legacy route)
3. Remove from `GAMES_CONFIG` in `/src/components/GameSelector.tsx`
4. Remove from `GAME_TYPE_TO_NAME` in `/src/app/arcade/room/page.tsx`
5. Update `/src/lib/arcade/validation/types.ts` imports (if referencing old types)
**Delete Legacy Files** (~30 files):
- `/src/app/arcade/matching/components/` (7 files + 1 test)
- `/src/app/arcade/matching/context/` (5 files + 1 test)
- `/src/app/arcade/matching/utils/` (3 files)
- `/src/app/games/matching/components/` (7 files + 1 test + 1 story)
- `/src/app/games/matching/context/` (2 files)
- `/src/app/games/matching/utils/` (3 files)
- `/src/lib/arcade/validation/MatchingGameValidator.ts` (moved)
**Files Modified**:
- `/src/app/arcade/matching/page.tsx` (redirect)
- `/src/app/games/matching/page.tsx` (redirect)
- `/src/components/GameSelector.tsx` (remove from GAMES_CONFIG)
- `/src/app/arcade/room/page.tsx` (remove from GAME_TYPE_TO_NAME)
---
## Testing Checklist
After migration, verify:
- [ ] Type checking passes (`npm run type-check`)
- [ ] Format/lint passes (`npm run pre-commit`)
- [ ] EmojiPicker test passes
- [ ] PlayerMetadata test passes
- [ ] Game loads in room mode
- [ ] Game selector shows one "Matching Pairs Battle" button
- [ ] Settings persist when changed in setup
- [ ] Turn-based gameplay works (only current player can flip)
- [ ] Card matching works (both abacus-numeral and complement-pairs)
- [ ] Pause/Resume works
- [ ] Hover state shows for other players
- [ ] Mismatch feedback displays correctly
- [ ] Results phase calculates scores correctly
---
## Migration Steps Summary
**8 Phases**:
1. ✅ Pre-Migration Audit - Compare duplicate files
2. ⏳ Create Modular Game Definition - Registry + types
3. ⏳ Move and Update Validator - Move to new location
4. ⏳ Consolidate and Move Types - SDK-compatible types
5. ⏳ Create Unified Provider - Room-only provider
6. ⏳ Consolidate and Move Components - Choose canonical versions
7. ⏳ Move Utility Functions - Consolidate utils
8. ⏳ Update Routes and Clean Up - Delete legacy files
**Estimated Effort**: 4-6 hours (larger than memory-quiz due to dual locations and more complexity)
---
## Key Differences from Memory Quiz Migration
1. **Dual Locations**: Must consolidate two separate implementations
2. **More Complex**: Turn-based multiplayer vs cooperative team play
3. **Partial Migration**: RoomProvider already uses useArcadeSession
4. **More Components**: 7 game components + 2 shared
5. **Existing Tests**: Must maintain test coverage
6. **Two Routes**: Both `/arcade/matching` and `/games/matching` exist
---
## Risks and Mitigation
### Risk 1: File Divergence
**Risk**: Duplicate files may have different features/fixes
**Mitigation**: Manually diff each duplicate pair, merge best of both
### Risk 2: Test Breakage
**Risk**: PlayerMetadata test may break during migration
**Mitigation**: Run tests frequently, update test if needed
### Risk 3: Turn Logic Complexity
**Risk**: Player ownership and turn validation is complex
**Mitigation**: Validator already handles this - trust existing logic
### Risk 4: Unknown Dependencies
**Risk**: Other parts of codebase may depend on `/games/matching/`
**Mitigation**: Search for imports before deletion: `grep -r "from.*games/matching" src/`
---
## Post-Migration Verification
After completing all phases:
1. Run full test suite
2. Manual testing:
- Create room
- Select "Matching Pairs Battle"
- Configure settings (verify persistence)
- Start game with multiple players
- Play several turns (verify turn order)
- Pause and resume
- Complete game (verify results)
3. Verify no duplicate game buttons
4. Check browser console for errors
5. Verify settings load correctly on page refresh
---
## References
- Memory Quiz Migration Plan: `docs/MEMORY_QUIZ_MIGRATION_PLAN.md`
- Game Migration Playbook: `docs/GAME_MIGRATION_PLAYBOOK.md`
- Game SDK Documentation: `.claude/GAME_SDK_DOCUMENTATION.md`
- Settings Persistence: `.claude/GAME_SETTINGS_PERSISTENCE.md`

View File

@@ -0,0 +1,676 @@
# Memory Quiz Migration Plan
**Game**: Memory Lightning (memory-quiz)
**Date**: 2025-01-16
**Target**: Migrate to Modular Game Platform (Game SDK)
---
## Executive Summary
Migrate the Memory Lightning game from the legacy architecture to the new modular game platform. This game is unique because:
- ✅ Already has a validator (`MemoryQuizGameValidator`)
- ✅ Already uses `useArcadeSession` in room mode
- ❌ Located in `/app/arcade/memory-quiz/` instead of `/arcade-games/`
- ❌ Uses reducer pattern instead of server-driven state
- ❌ Not using Game SDK types and structure
**Complexity**: **Medium-High** (4-6 hours)
**Risk**: Low (validator already exists, well-tested game)
---
## Current Architecture
### File Structure
```
src/app/arcade/memory-quiz/
├── page.tsx # Main page (local mode)
├── types.ts # State and move types
├── reducer.ts # State reducer (local only)
├── context/
│ ├── MemoryQuizContext.tsx # Context interface
│ ├── LocalMemoryQuizProvider.tsx # Local (solo) provider
│ └── RoomMemoryQuizProvider.tsx # Multiplayer provider
└── components/
├── MemoryQuizGame.tsx # Game wrapper component
├── SetupPhase.tsx # Setup/lobby UI
├── DisplayPhase.tsx # Card display phase
├── InputPhase.tsx # Input/guessing phase
├── ResultsPhase.tsx # End game results
├── CardGrid.tsx # Card display component
└── ResultsCardGrid.tsx # Results card display
src/lib/arcade/validation/
└── MemoryQuizGameValidator.ts # Server validator (✅ exists!)
```
### Important Notes
**⚠️ Local Mode Deprecated**: This migration only supports room mode. All games must be played in a room (even solo play is a single-player room). No local/offline mode code should be included.
### Current State Type (`SorobanQuizState`)
```typescript
interface SorobanQuizState {
// Core game data
cards: QuizCard[]
quizCards: QuizCard[]
correctAnswers: number[]
// Game progression
currentCardIndex: number
displayTime: number
selectedCount: 2 | 5 | 8 | 12 | 15
selectedDifficulty: DifficultyLevel
// Input system state
foundNumbers: number[]
guessesRemaining: number
currentInput: string
incorrectGuesses: number
// Multiplayer state
activePlayers: string[]
playerMetadata: Record<string, PlayerMetadata>
playerScores: Record<string, PlayerScore>
playMode: 'cooperative' | 'competitive'
numberFoundBy: Record<number, string>
// UI state
gamePhase: 'setup' | 'display' | 'input' | 'results'
prefixAcceptanceTimeout: NodeJS.Timeout | null
finishButtonsBound: boolean
wrongGuessAnimations: Array<{...}>
// Keyboard state
hasPhysicalKeyboard: boolean | null
testingMode: boolean
showOnScreenKeyboard: boolean
}
```
### Current Move Types
```typescript
type MemoryQuizGameMove =
| { type: 'START_QUIZ'; data: { numbers: number[], activePlayers, playerMetadata } }
| { type: 'NEXT_CARD' }
| { type: 'SHOW_INPUT_PHASE' }
| { type: 'ACCEPT_NUMBER'; data: { number: number } }
| { type: 'REJECT_NUMBER' }
| { type: 'SET_INPUT'; data: { input: string } }
| { type: 'SHOW_RESULTS' }
| { type: 'RESET_QUIZ' }
| { type: 'SET_CONFIG'; data: { field, value } }
```
### Current Config
```typescript
interface MemoryQuizGameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: 'beginner' | 'easy' | 'medium' | 'hard' | 'expert'
playMode: 'cooperative' | 'competitive'
}
```
---
## Target Architecture
### New File Structure
```
src/arcade-games/memory-quiz/ # NEW location
├── index.ts # Game definition (defineGame)
├── Validator.ts # Move from /lib/arcade/validation/
├── Provider.tsx # Single unified provider
├── types.ts # State, config, move types
├── game.yaml # Manifest (optional)
└── components/
├── GameComponent.tsx # Main game wrapper
├── SetupPhase.tsx # Setup UI (updated)
├── DisplayPhase.tsx # Display phase (minimal changes)
├── InputPhase.tsx # Input phase (minimal changes)
├── ResultsPhase.tsx # Results (minimal changes)
├── CardGrid.tsx # Unchanged
└── ResultsCardGrid.tsx # Unchanged
```
### New Provider Pattern
- ✅ Single provider (room mode only)
- ✅ Uses `useArcadeSession` with `roomId` (always provided)
- ✅ Uses Game SDK hooks (`useViewerId`, `useRoomData`, `useGameMode`)
- ✅ All state driven by server validator (no client reducer)
- ✅ All settings persist to room config automatically
---
## Migration Steps
### Phase 1: Preparation (1 hour)
**Goal**: Set up new structure without breaking existing game
1. ✅ Create `/src/arcade-games/memory-quiz/` directory
2. ✅ Copy Validator from `/lib/arcade/validation/` to new location
3. ✅ Update Validator to use Game SDK types if needed
4. ✅ Create `index.ts` stub for game definition
5. ✅ Copy `types.ts` to new location (will be updated)
6. ✅ Document what needs to change in each file
**Verification**: Existing game still works, new directory has scaffold
---
### Phase 2: Create Game Definition (1 hour)
**Goal**: Define the game using `defineGame()` helper
**Steps**:
1. Create `game.yaml` manifest (optional but recommended)
```yaml
name: memory-quiz
displayName: Memory Lightning
icon: 🧠
description: Memorize soroban numbers and recall them
longDescription: |
Flash cards with soroban numbers. Memorize them during the display
phase, then recall and type them during the input phase.
maxPlayers: 8
difficulty: Intermediate
chips:
- 👥 Multiplayer
- ⚡ Fast-Paced
- 🧠 Memory Challenge
color: blue
gradient: linear-gradient(135deg, #dbeafe, #bfdbfe)
borderColor: blue.200
available: true
```
2. Create `index.ts` game definition:
```typescript
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { GameComponent } from './components/GameComponent'
import { MemoryQuizProvider } from './Provider'
import type { MemoryQuizConfig, MemoryQuizMove, MemoryQuizState } from './types'
import { memoryQuizValidator } from './Validator'
const manifest: GameManifest = {
name: 'memory-quiz',
displayName: 'Memory Lightning',
icon: '🧠',
// ... (copy from game.yaml or define inline)
}
const defaultConfig: MemoryQuizConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: 'easy',
playMode: 'cooperative',
}
function validateMemoryQuizConfig(config: unknown): config is MemoryQuizConfig {
return (
typeof config === 'object' &&
config !== null &&
'selectedCount' in config &&
'displayTime' in config &&
'selectedDifficulty' in config &&
'playMode' in config &&
[2, 5, 8, 12, 15].includes((config as any).selectedCount) &&
typeof (config as any).displayTime === 'number' &&
(config as any).displayTime > 0 &&
['beginner', 'easy', 'medium', 'hard', 'expert'].includes(
(config as any).selectedDifficulty
) &&
['cooperative', 'competitive'].includes((config as any).playMode)
)
}
export const memoryQuizGame = defineGame<
MemoryQuizConfig,
MemoryQuizState,
MemoryQuizMove
>({
manifest,
Provider: MemoryQuizProvider,
GameComponent,
validator: memoryQuizValidator,
defaultConfig,
validateConfig: validateMemoryQuizConfig,
})
```
3. Register game in `game-registry.ts`:
```typescript
import { memoryQuizGame } from '@/arcade-games/memory-quiz'
registerGame(memoryQuizGame)
```
4. Update `validators.ts` to import from new location:
```typescript
import { memoryQuizValidator } from '@/arcade-games/memory-quiz/Validator'
```
5. Add type inference to `game-configs.ts`:
```typescript
import type { memoryQuizGame } from '@/arcade-games/memory-quiz'
export type MemoryQuizGameConfig = InferGameConfig<typeof memoryQuizGame>
```
**Verification**: Game definition compiles, validator registered
---
### Phase 3: Update Types (30 minutes)
**Goal**: Ensure types match Game SDK expectations
**Changes to `types.ts`**:
1. Rename `SorobanQuizState` → `MemoryQuizState`
2. Ensure `MemoryQuizState` extends `GameState` from SDK
3. Rename move types to match SDK patterns
4. Export proper config type
**Example**:
```typescript
import type { GameConfig, GameState, GameMove } from '@/lib/arcade/game-sdk'
export interface MemoryQuizConfig extends GameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
}
export interface MemoryQuizState extends GameState {
// Core game data
cards: QuizCard[]
quizCards: QuizCard[]
correctAnswers: number[]
// Game progression
currentCardIndex: number
displayTime: number
selectedCount: number
selectedDifficulty: DifficultyLevel
// Input system state
foundNumbers: number[]
guessesRemaining: number
currentInput: string
incorrectGuesses: number
// Multiplayer state (from GameState)
activePlayers: string[]
playerMetadata: Record<string, PlayerMetadata>
// Game-specific multiplayer
playerScores: Record<string, PlayerScore>
playMode: 'cooperative' | 'competitive'
numberFoundBy: Record<number, string>
// UI state
gamePhase: 'setup' | 'display' | 'input' | 'results'
prefixAcceptanceTimeout: NodeJS.Timeout | null
finishButtonsBound: boolean
wrongGuessAnimations: Array<{...}>
// Keyboard state
hasPhysicalKeyboard: boolean | null
testingMode: boolean
showOnScreenKeyboard: boolean
}
export type MemoryQuizMove =
| { type: 'START_QUIZ'; playerId: string; userId: string; timestamp: number; data: {...} }
| { type: 'NEXT_CARD'; playerId: string; userId: string; timestamp: number; data: {} }
// ... (ensure all moves have playerId, userId, timestamp)
```
**Key Changes**:
- All moves must have `playerId`, `userId`, `timestamp` (SDK requirement)
- State should include `activePlayers` and `playerMetadata` (SDK standard)
- Use `TEAM_MOVE` for moves where specific player doesn't matter
**Verification**: Types compile, validator accepts move types
---
### Phase 4: Create Provider (2 hours)
**Goal**: Single provider for room mode (only mode supported)
**Key Pattern**:
```typescript
'use client'
import { useCallback, useMemo } from 'react'
import {
useArcadeSession,
useGameMode,
useRoomData,
useViewerId,
useUpdateGameConfig,
buildPlayerMetadata,
} from '@/lib/arcade/game-sdk'
import type { MemoryQuizState, MemoryQuizMove } from './types'
export function MemoryQuizProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
const activePlayers = Array.from(activePlayerIds)
// Merge saved config from room
const initialState = useMemo(() => {
const gameConfig = roomData?.gameConfig?.['memory-quiz']
return {
// ... default state
displayTime: gameConfig?.displayTime ?? 2.0,
selectedCount: gameConfig?.selectedCount ?? 5,
selectedDifficulty: gameConfig?.selectedDifficulty ?? 'easy',
playMode: gameConfig?.playMode ?? 'cooperative',
// ... rest of state
}
}, [roomData])
const { state, sendMove, exitSession, lastError, clearError } =
useArcadeSession<MemoryQuizState>({
userId: viewerId || '',
roomId: roomData?.id, // Always provided (room mode only)
initialState,
applyMove: (state) => state, // Server handles all updates
})
// Action creators
const startQuiz = useCallback((quizCards: QuizCard[]) => {
const numbers = quizCards.map(c => c.number)
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId)
sendMove({
type: 'START_QUIZ',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { numbers, quizCards, activePlayers, playerMetadata },
})
}, [viewerId, sendMove, activePlayers, players])
// ... more action creators
return (
<MemoryQuizContext.Provider value={{
state,
startQuiz,
// ... all other actions
lastError,
clearError,
exitSession,
}}>
{children}
</MemoryQuizContext.Provider>
)
}
```
**Key Changes from Current RoomProvider**:
1. ✅ No reducer - server handles all state
2. ✅ Uses SDK hooks exclusively
3. ✅ Simpler action creators (server does the work)
4. ✅ Config persistence via `useUpdateGameConfig`
5. ✅ Always uses roomId (no conditional logic)
**Files to Delete**:
- ❌ `reducer.ts` (no longer needed)
- ❌ `LocalMemoryQuizProvider.tsx` (local mode deprecated)
- ❌ Client-side `applyMoveOptimistically()` (server authoritative)
**Verification**: Provider compiles, context works
---
### Phase 5: Update Components (1 hour)
**Goal**: Update components to use new provider API
**Changes Needed**:
1. **GameComponent.tsx** (new file):
```typescript
'use client'
import { useRouter } from 'next/navigation'
import { PageWithNav } from '@/components/PageWithNav'
import { useMemoryQuiz } from '../Provider'
import { SetupPhase } from './SetupPhase'
import { DisplayPhase } from './DisplayPhase'
import { InputPhase } from './InputPhase'
import { ResultsPhase } from './ResultsPhase'
export function GameComponent() {
const router = useRouter()
const { state, exitSession } = useMemoryQuiz()
return (
<PageWithNav
navTitle="Memory Lightning"
navEmoji="🧠"
emphasizePlayerSelection={state.gamePhase === 'setup'}
onExitSession={() => {
exitSession()
router.push('/arcade')
}}
>
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'display' && <DisplayPhase />}
{state.gamePhase === 'input' && <InputPhase key="input-phase" />}
{state.gamePhase === 'results' && <ResultsPhase />}
</PageWithNav>
)
}
```
2. **SetupPhase.tsx**: Update to use action creators instead of dispatch
```diff
- dispatch({ type: 'SET_DIFFICULTY', difficulty: value })
+ setConfig('selectedDifficulty', value)
```
3. **DisplayPhase.tsx**: Update to use `nextCard` action
```diff
- dispatch({ type: 'NEXT_CARD' })
+ nextCard()
```
4. **InputPhase.tsx**: Update to use `acceptNumber`, `rejectNumber` actions
```diff
- dispatch({ type: 'ACCEPT_NUMBER', number })
+ acceptNumber(number)
```
5. **ResultsPhase.tsx**: Update to use `resetGame`, `showResults` actions
```diff
- dispatch({ type: 'RESET_QUIZ' })
+ resetGame()
```
**Minimal Changes**:
- Components mostly stay the same
- Replace `dispatch()` calls with action creators
- No other UI changes needed
**Verification**: All phases render, actions work
---
### Phase 6: Update Page Route (15 minutes)
**Goal**: Update page to use new game definition
**New `/app/arcade/memory-quiz/page.tsx`**:
```typescript
'use client'
import { memoryQuizGame } from '@/arcade-games/memory-quiz'
const { Provider, GameComponent } = memoryQuizGame
export default function MemoryQuizPage() {
return (
<Provider>
<GameComponent />
</Provider>
)
}
```
**That's it!** The game now uses the modular system.
**Verification**: Game loads and plays end-to-end
---
### Phase 7: Testing (30 minutes)
**Goal**: Verify all functionality works
**Test Cases**:
1. **Solo Play** (single player in room):
- [ ] Setup phase renders
- [ ] Can change all settings (count, difficulty, display time, play mode)
- [ ] Can start quiz
- [ ] Cards display with timing
- [ ] Input phase works
- [ ] Can type and submit answers
- [ ] Correct/incorrect feedback works
- [ ] Results phase shows scores
- [ ] Can play again
- [ ] Settings persist across page reloads
2. **Multiplayer** (multiple players):
- [ ] Settings persist across page reloads
- [ ] All players see same cards
- [ ] Timing synchronized (room creator controls)
- [ ] Input from any player works
- [ ] Scores track correctly per player
- [ ] Cooperative mode: team score works
- [ ] Competitive mode: individual scores work
- [ ] Results show all player scores
3. **Edge Cases**:
- [ ] Switching games preserves settings
- [ ] Leaving mid-game doesn't crash
- [ ] Keyboard detection works
- [ ] On-screen keyboard toggle works
- [ ] Wrong guess animations work
- [ ] Timeout handling works
**Verification**: All tests pass
---
## Breaking Changes
### For Users
- ✅ **None** - Game should work identically
### For Developers
- ❌ Can't use `dispatch()` anymore (use action creators)
- ❌ Can't access reducer (server-driven state only)
- ❌ No local mode support (room mode only)
---
## Rollback Plan
If migration fails:
1. Revert page to use old providers
2. Keep old files in place
3. Remove new `/arcade-games/memory-quiz/` directory
4. Unregister from game registry
**Time to rollback**: 5 minutes
---
## Post-Migration Tasks
1. ✅ Delete old files:
- `/app/arcade/memory-quiz/reducer.ts` (no longer needed)
- `/app/arcade/memory-quiz/context/LocalMemoryQuizProvider.tsx` (local mode deprecated)
- `/app/arcade/memory-quiz/page.tsx` (old local mode page, replaced by arcade page)
- `/lib/arcade/validation/MemoryQuizGameValidator.ts` (moved to new location)
2. ✅ Update imports across codebase
3. ✅ Add to `ARCHITECTURAL_IMPROVEMENTS.md`:
- Memory Quiz migrated successfully
- Now 3 games on modular platform
4. ✅ Run full test suite
---
## Complexity Analysis
### What Makes This Easier
- ✅ Validator already exists and works
- ✅ Already uses `useArcadeSession`
- ✅ Move types mostly match SDK requirements
- ✅ Well-tested, stable game
### What Makes This Harder
- ❌ Complex UI state (keyboard detection, animations)
- ❌ Two-phase gameplay (display, then input)
- ❌ Timing synchronization requirements
- ❌ Local input optimization (doesn't sync every keystroke)
### Estimated Time
- **Fast path** (no issues): 3-4 hours
- **Normal path** (minor fixes): 4-6 hours
- **Slow path** (major issues): 6-8 hours
---
## Success Criteria
1. ✅ Game registered in game registry
2. ✅ Config types inferred from game definition
3. ✅ Single provider for local and room modes
4. ✅ All phases work in both modes
5. ✅ Settings persist in room mode
6. ✅ Multiplayer synchronization works
7. ✅ No TypeScript errors
8. ✅ No lint errors
9. ✅ Pre-commit checks pass
10. ✅ Manual testing confirms all features work
---
## Notes
### UI State Challenges
Memory Quiz has significant UI-only state:
- `wrongGuessAnimations` - visual feedback
- `hasPhysicalKeyboard` - device detection
- `showOnScreenKeyboard` - toggle state
- `prefixAcceptanceTimeout` - timeout handling
**Solution**: These can remain client-only (not synced). They don't affect game logic.
### Input Optimization
Current implementation doesn't sync `currentInput` over network (only final submission).
**Solution**: Keep this pattern. Use local state for input, only sync `ACCEPT_NUMBER`/`REJECT_NUMBER`.
### Timing Synchronization
Room creator controls card timing (NEXT_CARD moves).
**Solution**: Check `isRoomCreator` flag, only creator can advance cards.
---
## References
- Game SDK Documentation: `/src/arcade-games/README.md`
- Example Migration: Number Guesser, Math Sprint
- Architecture Docs: `/docs/ARCHITECTURAL_IMPROVEMENTS.md`
- Validator Registry: `/src/lib/arcade/validators.ts`
- Game Registry: `/src/lib/arcade/game-registry.ts`

View File

@@ -1,113 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useReducer } from 'react'
import { useRouter } from 'next/navigation'
import { initialState, quizReducer } from '../reducer'
import type { QuizCard } from '../types'
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
interface LocalMemoryQuizProviderProps {
children: ReactNode
}
/**
* LocalMemoryQuizProvider - Provides context for single-player local mode
*
* This provider wraps the memory quiz reducer and provides action creators
* to child components. It's used for standalone local play (non-room mode).
*
* Action creators wrap dispatch calls to maintain same interface as RoomProvider.
*/
export function LocalMemoryQuizProvider({ children }: LocalMemoryQuizProviderProps) {
const router = useRouter()
const [state, dispatch] = useReducer(quizReducer, initialState)
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (state.prefixAcceptanceTimeout) {
clearTimeout(state.prefixAcceptanceTimeout)
}
}
}, [state.prefixAcceptanceTimeout])
// Computed values
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
// Action creators - wrap dispatch calls to match RoomProvider interface
const startQuiz = useCallback((quizCards: QuizCard[]) => {
dispatch({ type: 'START_QUIZ', quizCards })
}, [])
const nextCard = useCallback(() => {
dispatch({ type: 'NEXT_CARD' })
}, [])
const showInputPhase = useCallback(() => {
dispatch({ type: 'SHOW_INPUT_PHASE' })
}, [])
const acceptNumber = useCallback((number: number) => {
dispatch({ type: 'ACCEPT_NUMBER', number })
}, [])
const rejectNumber = useCallback(() => {
dispatch({ type: 'REJECT_NUMBER' })
}, [])
const setInput = useCallback((input: string) => {
dispatch({ type: 'SET_INPUT', input })
}, [])
const showResults = useCallback(() => {
dispatch({ type: 'SHOW_RESULTS' })
}, [])
const resetGame = useCallback(() => {
dispatch({ type: 'RESET_QUIZ' })
}, [])
const setConfig = useCallback(
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => {
switch (field) {
case 'selectedCount':
dispatch({ type: 'SET_SELECTED_COUNT', count: value })
break
case 'displayTime':
dispatch({ type: 'SET_DISPLAY_TIME', time: value })
break
case 'selectedDifficulty':
dispatch({ type: 'SET_DIFFICULTY', difficulty: value })
break
}
},
[]
)
const exitSession = useCallback(() => {
router.push('/games')
}, [router])
const contextValue: MemoryQuizContextValue = {
state,
dispatch: () => {
// No-op - local provider uses action creators instead
console.warn('dispatch() is not available in local mode, use action creators instead')
},
isGameActive,
resetGame,
exitSession,
// Expose action creators for components to use
startQuiz,
nextCard,
showInputPhase,
acceptNumber,
rejectNumber,
setInput,
showResults,
setConfig,
}
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
}

View File

@@ -1,45 +0,0 @@
'use client'
import { createContext, useContext } from 'react'
import type { QuizAction, QuizCard, SorobanQuizState } from '../types'
// Context value interface
export interface MemoryQuizContextValue {
state: SorobanQuizState
dispatch: React.Dispatch<QuizAction>
// Computed values
isGameActive: boolean
isRoomCreator?: boolean // True if current user is room creator (controls timing in multiplayer)
// Action creators (to be implemented by providers)
// Local mode uses dispatch, room mode uses these action creators
startGame?: () => void
resetGame?: () => void
exitSession?: () => void
// Room mode action creators (optional for local mode)
startQuiz?: (quizCards: QuizCard[]) => void
nextCard?: () => void
showInputPhase?: () => void
acceptNumber?: (number: number) => void
rejectNumber?: () => void
setInput?: (input: string) => void
showResults?: () => void
setConfig?: (
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: any
) => void
}
// Create context
export const MemoryQuizContext = createContext<MemoryQuizContextValue | null>(null)
// Hook to use the context
export function useMemoryQuiz(): MemoryQuizContextValue {
const context = useContext(MemoryQuizContext)
if (!context) {
throw new Error('useMemoryQuiz must be used within a MemoryQuizProvider')
}
return context
}

View File

@@ -1,157 +1,70 @@
'use client'
import Link from 'next/link'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../styled-system/css'
import { DisplayPhase } from './components/DisplayPhase'
import { InputPhase } from './components/InputPhase'
import { ResultsPhase } from './components/ResultsPhase'
import { SetupPhase } from './components/SetupPhase'
import { LocalMemoryQuizProvider } from './context/LocalMemoryQuizProvider'
import { useMemoryQuiz } from './context/MemoryQuizContext'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
// CSS animations that need to be global
const globalAnimations = `
@keyframes pulse {
0% { transform: scale(1); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
50% { transform: scale(1.05); box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5); }
100% { transform: scale(1); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
}
/**
* Memory Quiz redirect page
*
* Local mode has been deprecated. Memory Quiz is now only available
* through the Champion Arena (arcade) in room mode.
*
* This page redirects users to the arcade where they can:
* 1. Create or join a room
* 2. Select Memory Lightning from the game selector
* 3. Play in multiplayer or solo (single-player room)
*/
export default function MemoryQuizRedirectPage() {
const router = useRouter()
@keyframes subtlePageFlash {
0% { background: linear-gradient(to bottom right, #f0fdf4, #ecfdf5); }
50% { background: linear-gradient(to bottom right, #dcfce7, #d1fae5); }
100% { background: linear-gradient(to bottom right, #f0fdf4, #ecfdf5); }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
@keyframes explode {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
50% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.5);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(2) rotate(180deg);
}
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
`
// Inner component that uses the context
function MemoryQuizGame() {
const { state } = useMemoryQuiz()
useEffect(() => {
// Redirect to arcade
router.replace('/arcade')
}, [router])
return (
<>
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
padding: '20px',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
padding: '20px 8px',
minHeight: '100vh',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
fontSize: '48px',
marginBottom: '20px',
}}
>
<div
style={{
maxWidth: '100%',
margin: '0 auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<div
className={css({
textAlign: 'center',
mb: '4',
flexShrink: 0,
})}
>
<Link
href="/games"
className={css({
display: 'inline-flex',
alignItems: 'center',
color: 'gray.600',
textDecoration: 'none',
mb: '4',
_hover: { color: 'gray.800' },
})}
>
Back to Games
</Link>
</div>
<div
className={css({
bg: 'white',
rounded: 'xl',
shadow: 'xl',
overflow: 'hidden',
border: '1px solid',
borderColor: 'gray.200',
flex: 1,
display: 'flex',
flexDirection: 'column',
maxHeight: '100%',
})}
>
<div
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
})}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'display' && <DisplayPhase />}
{state.gamePhase === 'input' && <InputPhase key="input-phase" />}
{state.gamePhase === 'results' && <ResultsPhase />}
</div>
</div>
</div>
🧠
</div>
</>
)
}
// Main page component that provides the context
export default function MemoryQuizPage() {
return (
<PageWithNav navTitle="Memory Lightning" navEmoji="🧠">
<LocalMemoryQuizProvider>
<MemoryQuizGame />
</LocalMemoryQuizProvider>
</PageWithNav>
<h1
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#1f2937',
textAlign: 'center',
}}
>
Redirecting to Champion Arena...
</h1>
<p
style={{
fontSize: '16px',
color: '#6b7280',
textAlign: 'center',
maxWidth: '500px',
}}
>
Memory Lightning is now part of the Champion Arena.
<br />
You'll be able to play solo or with friends in multiplayer mode!
</p>
</div>
)
}

View File

@@ -1,138 +0,0 @@
import type { QuizAction, SorobanQuizState } from './types'
export const initialState: SorobanQuizState = {
cards: [],
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
displayTime: 2.0,
selectedCount: 5,
selectedDifficulty: 'easy', // Default to easy level
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
// Multiplayer state
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: 'cooperative', // Default to cooperative
numberFoundBy: {},
// UI state
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
wrongGuessAnimations: [],
// Keyboard state (persistent across re-renders)
hasPhysicalKeyboard: null,
testingMode: false,
showOnScreenKeyboard: false,
}
export function quizReducer(state: SorobanQuizState, action: QuizAction): SorobanQuizState {
switch (action.type) {
case 'SET_CARDS':
return { ...state, cards: action.cards }
case 'SET_DISPLAY_TIME':
return { ...state, displayTime: action.time }
case 'SET_SELECTED_COUNT':
return { ...state, selectedCount: action.count }
case 'SET_DIFFICULTY':
return { ...state, selectedDifficulty: action.difficulty }
case 'SET_PLAY_MODE':
return { ...state, playMode: action.playMode }
case 'START_QUIZ':
return {
...state,
quizCards: action.quizCards,
correctAnswers: action.quizCards.map((card) => card.number),
currentCardIndex: 0,
foundNumbers: [],
guessesRemaining: action.quizCards.length + Math.floor(action.quizCards.length / 2),
gamePhase: 'display',
}
case 'NEXT_CARD':
return { ...state, currentCardIndex: state.currentCardIndex + 1 }
case 'SHOW_INPUT_PHASE':
return { ...state, gamePhase: 'input' }
case 'ACCEPT_NUMBER': {
// In competitive mode, track which player guessed correctly
const newPlayerScores = { ...state.playerScores }
if (state.playMode === 'competitive' && action.playerId) {
const currentScore = newPlayerScores[action.playerId] || { correct: 0, incorrect: 0 }
newPlayerScores[action.playerId] = {
...currentScore,
correct: currentScore.correct + 1,
}
}
return {
...state,
foundNumbers: [...state.foundNumbers, action.number],
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'REJECT_NUMBER': {
// In competitive mode, track which player guessed incorrectly
const newPlayerScores = { ...state.playerScores }
if (state.playMode === 'competitive' && action.playerId) {
const currentScore = newPlayerScores[action.playerId] || { correct: 0, incorrect: 0 }
newPlayerScores[action.playerId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'SET_INPUT':
return { ...state, currentInput: action.input }
case 'SET_PREFIX_TIMEOUT':
return { ...state, prefixAcceptanceTimeout: action.timeout }
case 'ADD_WRONG_GUESS_ANIMATION':
return {
...state,
wrongGuessAnimations: [
...state.wrongGuessAnimations,
{
number: action.number,
id: `wrong-${action.number}-${Date.now()}`,
timestamp: Date.now(),
},
],
}
case 'CLEAR_WRONG_GUESS_ANIMATIONS':
return {
...state,
wrongGuessAnimations: [],
}
case 'SHOW_RESULTS':
return { ...state, gamePhase: 'results' }
case 'RESET_QUIZ':
return {
...initialState,
cards: state.cards, // Preserve generated cards
displayTime: state.displayTime,
selectedCount: state.selectedCount,
selectedDifficulty: state.selectedDifficulty,
playMode: state.playMode, // Preserve play mode
// Preserve keyboard state across resets
hasPhysicalKeyboard: state.hasPhysicalKeyboard,
testingMode: state.testingMode,
showOnScreenKeyboard: state.showOnScreenKeyboard,
}
case 'SET_PHYSICAL_KEYBOARD':
return { ...state, hasPhysicalKeyboard: action.hasKeyboard }
case 'SET_TESTING_MODE':
return { ...state, testingMode: action.enabled }
case 'TOGGLE_ONSCREEN_KEYBOARD':
return { ...state, showOnScreenKeyboard: !state.showOnScreenKeyboard }
default:
return state
}
}

View File

@@ -4,8 +4,6 @@ import { useRouter } from 'next/navigation'
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
import { MemoryQuizGame } from '../memory-quiz/components/MemoryQuizGame'
import { RoomMemoryQuizProvider } from '../memory-quiz/context/RoomMemoryQuizProvider'
import { GAMES_CONFIG } from '@/components/GameSelector'
import type { GameType } from '@/components/GameSelector'
import { PageWithNav } from '@/components/PageWithNav'
@@ -15,7 +13,6 @@ import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
// Map GameType keys to internal game names
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
'battle-arena': 'matching',
'memory-quiz': 'memory-quiz',
'complement-race': 'complement-race',
'master-organizer': 'master-organizer',
}
@@ -343,13 +340,6 @@ export default function RoomPage() {
</RoomMemoryPairsProvider>
)
case 'memory-quiz':
return (
<RoomMemoryQuizProvider>
<MemoryQuizGame />
</RoomMemoryQuizProvider>
)
// TODO: Add other games (complement-race, etc.)
default:
return (

View File

@@ -38,12 +38,44 @@ A modular, plugin-based architecture for building multiplayer arcade games with
## Architecture
### Key Improvements
**✨ Phase 3: Type Inference (January 2025)**
Config types are now **automatically inferred** from game definitions for modern games. No more manual type definitions!
```typescript
// Before Phase 3: Manual type definition
export interface NumberGuesserGameConfig {
minNumber: number
maxNumber: number
roundsToWin: number
}
// After Phase 3: Inferred from game definition
export type NumberGuesserGameConfig = InferGameConfig<typeof numberGuesserGame>
```
**Benefits**:
- Add a game → Config types automatically available system-wide
- Single source of truth (the game definition)
- Eliminates 10-15 lines of boilerplate per game
### System Components
```
┌─────────────────────────────────────────────────────────────┐
│ Validator Registry │
│ - Server-side validators (isomorphic) │
│ - Single source of truth for game names │
│ - Auto-derived GameName type │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Game Registry │
│ - Registers all available games
│ - Client-side game definitions
│ - React components (Provider, GameComponent) │
│ - Provides game discovery │
└─────────────────────────────────────────────────────────────┘
@@ -53,18 +85,27 @@ A modular, plugin-based architecture for building multiplayer arcade games with
│ - Stable API surface for games │
│ - React hooks (useArcadeSession, useRoomData, etc.) │
│ - Type definitions and utilities │
│ - defineGame() helper │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Individual Games │
│ number-guesser/ │
│ ├── index.ts (Game definition)
│ ├── Validator.ts (Server validation)
│ ├── index.ts (Game definition + validation)
│ ├── Validator.ts (Server validation logic)
│ ├── Provider.tsx (Client state management) │
│ ├── GameComponent.tsx (Main UI) │
│ ├── types.ts (TypeScript types) │
│ └── components/ (Phase UIs) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Type System (NEW) │
│ - Config types inferred from game definitions │
│ - GameConfigByName auto-derived │
│ - RoomGameConfig auto-derived │
└─────────────────────────────────────────────────────────────┘
```
@@ -130,9 +171,12 @@ interface GameDefinition<TConfig, TState, TMove> {
GameComponent: GameComponent // Main UI component
validator: GameValidator // Server-side validation
defaultConfig: TConfig // Default game settings
validateConfig?: (config: unknown) => config is TConfig // Runtime config validation
}
```
**Key Concept**: The `defaultConfig` property serves as the source of truth for config types. TypeScript can infer the config type from `typeof game.defaultConfig`, eliminating the need for manual type definitions in `game-configs.ts`.
#### GameState
The complete game state that's synchronized across all clients:
@@ -430,15 +474,32 @@ const defaultConfig: MyGameConfig = {
timer: 30,
}
// Runtime config validation (optional but recommended)
function validateMyGameConfig(config: unknown): config is MyGameConfig {
return (
typeof config === 'object' &&
config !== null &&
'difficulty' in config &&
'timer' in config &&
typeof config.difficulty === 'number' &&
typeof config.timer === 'number' &&
config.difficulty >= 1 &&
config.timer >= 10
)
}
export const myGame = defineGame<MyGameConfig, MyGameState, MyGameMove>({
manifest,
Provider: MyGameProvider,
GameComponent,
validator: myGameValidator,
defaultConfig,
validateConfig: validateMyGameConfig, // Self-contained validation
})
```
**Phase 3 Benefit**: After defining your game, the config type will be automatically inferred in `game-configs.ts`. You don't need to manually add type definitions - just add a type-only import and use `InferGameConfig<typeof myGame>`.
### Step 7: Register Game
#### 7a. Register Validator (Server-Side)
@@ -480,6 +541,46 @@ registerGame(myGame)
**Important**: Both steps are required for a working game. The validator registry handles server logic, while the game registry handles client UI.
#### 7c. Add Config Type Inference (Optional but Recommended)
Update `src/lib/arcade/game-configs.ts` to infer your game's config type:
```typescript
// Add type-only import (won't load React components)
import type { myGame } from '@/arcade-games/my-game'
// Utility type (already defined)
type InferGameConfig<T> = T extends { defaultConfig: infer Config } ? Config : never
// Infer your config type
export type MyGameConfig = InferGameConfig<typeof myGame>
// Add to GameConfigByName
export type GameConfigByName = {
// ... other games
'my-game': MyGameConfig // TypeScript infers the type automatically!
}
// RoomGameConfig is auto-derived from GameConfigByName
export type RoomGameConfig = {
[K in keyof GameConfigByName]?: GameConfigByName[K]
}
// Add default config constant
export const DEFAULT_MY_GAME_CONFIG: MyGameConfig = {
difficulty: 1,
timer: 30,
}
```
**Benefits**:
- Config type automatically matches your game definition
- No manual type definition needed
- Single source of truth (your game's `defaultConfig`)
- TypeScript will error if you reference undefined properties
**Note**: You still need to manually add the default config constant. This is a small amount of duplication but necessary for server-side code that can't import the full game definition.
---
## File Structure

View File

@@ -92,13 +92,14 @@ export function MathSprintProvider({ children }: { children: ReactNode }) {
)
// Arcade session integration
const { state, sendMove, exitSession, lastError, clearError } =
useArcadeSession<MathSprintState>({
const { state, sendMove, exitSession, lastError, clearError } = useArcadeSession<MathSprintState>(
{
userId: viewerId || '',
roomId: roomData?.id,
initialState,
applyMove: (state) => state, // Server handles all state updates
})
}
)
// Action: Start game
const startGame = useCallback(() => {

View File

@@ -6,7 +6,6 @@
*/
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import type {
Difficulty,
MathSprintConfig,
@@ -16,9 +15,7 @@ import type {
Question,
} from './types'
export class MathSprintValidator
implements GameValidator<MathSprintState, MathSprintMove>
{
export class MathSprintValidator implements GameValidator<MathSprintState, MathSprintMove> {
/**
* Validate a game move
*/
@@ -222,11 +219,7 @@ export class MathSprintValidator
return { valid: true, newState }
}
private validateSetConfig(
state: MathSprintState,
field: string,
value: any
): ValidationResult {
private validateSetConfig(state: MathSprintState, field: string, value: any): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Cannot change config during game' }
}
@@ -255,11 +248,7 @@ export class MathSprintValidator
return questions
}
private generateQuestion(
difficulty: Difficulty,
operation: Operation,
id: string
): Question {
private generateQuestion(difficulty: Difficulty, operation: Operation, id: string): Question {
let operand1: number
let operand2: number
let correctAnswer: number

View File

@@ -80,7 +80,9 @@ export function PlayingPhase() {
marginBottom: '8px',
})}
>
<span className={css({ fontSize: 'sm', fontWeight: 'semibold' })}>Question {progress}</span>
<span className={css({ fontSize: 'sm', fontWeight: 'semibold' })}>
Question {progress}
</span>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
</span>
@@ -211,7 +213,6 @@ export function PlayingPhase() {
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your answer..."
autoFocus
className={css({
flex: 1,
padding: '12px 16px',
@@ -334,7 +335,9 @@ export function PlayingPhase() {
{player?.name}
</span>
</div>
<span className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'purple.600' })}>
<span
className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'purple.600' })}
>
{score} pts
</span>
</div>

View File

@@ -137,7 +137,9 @@ export function SetupPhase() {
width: '100%',
})}
/>
<div className={css({ display: 'flex', justifyContent: 'space-between', fontSize: 'xs' })}>
<div
className={css({ display: 'flex', justifyContent: 'space-between', fontSize: 'xs' })}
>
<span>5</span>
<span>10</span>
<span>15</span>

View File

@@ -34,10 +34,28 @@ const defaultConfig: MathSprintConfig = {
timePerQuestion: 30,
}
// Config validation function
function validateMathSprintConfig(config: unknown): config is MathSprintConfig {
return (
typeof config === 'object' &&
config !== null &&
'difficulty' in config &&
'questionsPerRound' in config &&
'timePerQuestion' in config &&
['easy', 'medium', 'hard'].includes((config as any).difficulty) &&
typeof (config as any).questionsPerRound === 'number' &&
typeof (config as any).timePerQuestion === 'number' &&
(config as any).questionsPerRound >= 5 &&
(config as any).questionsPerRound <= 20 &&
(config as any).timePerQuestion >= 10
)
}
export const mathSprintGame = defineGame<MathSprintConfig, MathSprintState, MathSprintMove>({
manifest,
Provider: MathSprintProvider,
GameComponent,
validator: mathSprintValidator,
defaultConfig,
validateConfig: validateMathSprintConfig,
})

View File

@@ -1,32 +1,31 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import type { GameMove } from '@/lib/arcade/validation'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import {
buildPlayerMetadata as buildPlayerMetadataUtil,
buildPlayerOwnershipFromRoomData,
} from '@/lib/arcade/player-ownership.client'
import { initialState } from '../reducer'
import type { QuizCard, SorobanQuizState } from '../types'
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import type { QuizCard, MemoryQuizState, MemoryQuizMove } from './types'
import type { GameMove } from '@/lib/arcade/validation'
/**
* Optimistic move application (client-side prediction)
* The server will validate and send back the authoritative state
*/
function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): SorobanQuizState {
switch (move.type) {
function applyMoveOptimistically(state: MemoryQuizState, move: GameMove): MemoryQuizState {
const typedMove = move as MemoryQuizMove
switch (typedMove.type) {
case 'START_QUIZ': {
// Handle both client-generated moves (with quizCards) and server-generated moves (with numbers only)
// Server can't serialize React components, so it only sends numbers
const clientQuizCards = move.data.quizCards
const serverNumbers = move.data.numbers
const clientQuizCards = typedMove.data.quizCards
const serverNumbers = typedMove.data.numbers
let quizCards: QuizCard[]
let correctAnswers: number[]
@@ -36,7 +35,7 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
quizCards = clientQuizCards
correctAnswers = clientQuizCards.map((card: QuizCard) => card.number)
} else if (serverNumbers) {
// Server update: create minimal quizCards from numbers (no React components needed for validation)
// Server update: create minimal quizCards from numbers
quizCards = serverNumbers.map((number: number) => ({
number,
svgComponent: null,
@@ -44,18 +43,16 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
}))
correctAnswers = serverNumbers
} else {
// Fallback: preserve existing state
quizCards = state.quizCards
correctAnswers = state.correctAnswers
}
const cardCount = quizCards.length
// Initialize player scores for all active players (by userId, not playerId)
const activePlayers = move.data.activePlayers || []
const playerMetadata = move.data.playerMetadata || {}
// Initialize player scores for all active players (by userId)
const activePlayers = typedMove.data.activePlayers || []
const playerMetadata = typedMove.data.playerMetadata || {}
// Extract unique userIds from playerMetadata
const uniqueUserIds = new Set<string>()
for (const playerId of activePlayers) {
const metadata = playerMetadata[playerId]
@@ -64,11 +61,13 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
}
}
// Initialize scores for each userId
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
}, {})
const playerScores = Array.from(uniqueUserIds).reduce(
(acc: Record<string, { correct: number; incorrect: number }>, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
},
{}
)
return {
...state,
@@ -82,10 +81,10 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
// Multiplayer state
activePlayers,
playerMetadata,
playerScores,
numberFoundBy: {},
}
}
@@ -102,8 +101,6 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
}
case 'ACCEPT_NUMBER': {
// Track scores by userId (not playerId) since we can't determine which player typed
// Defensive check: ensure state properties exist
const playerScores = state.playerScores || {}
const foundNumbers = state.foundNumbers || []
const numberFoundBy = state.numberFoundBy || {}
@@ -111,44 +108,51 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
const newPlayerScores = { ...playerScores }
const newNumberFoundBy = { ...numberFoundBy }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
if (typedMove.userId) {
const currentScore = newPlayerScores[typedMove.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[typedMove.userId] = {
...currentScore,
correct: currentScore.correct + 1,
}
// Track who found this number
newNumberFoundBy[move.data.number] = move.userId
newNumberFoundBy[typedMove.data.number] = typedMove.userId
}
return {
...state,
foundNumbers: [...foundNumbers, move.data.number],
foundNumbers: [...foundNumbers, typedMove.data.number],
currentInput: '',
playerScores: newPlayerScores,
numberFoundBy: newNumberFoundBy,
}
}
case 'REJECT_NUMBER': {
// Track scores by userId (not playerId) since we can't determine which player typed
// Defensive check: ensure state properties exist
const playerScores = state.playerScores || {}
const newPlayerScores = { ...playerScores }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
if (typedMove.userId) {
const currentScore = newPlayerScores[typedMove.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[typedMove.userId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'SET_INPUT':
return {
...state,
currentInput: typedMove.data.input,
}
case 'SHOW_RESULTS':
return {
...state,
@@ -172,10 +176,7 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
}
case 'SET_CONFIG': {
const { field, value } = move.data as {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
value: any
}
const { field, value } = typedMove.data
return {
...state,
[field]: value,
@@ -187,65 +188,115 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
}
}
// Context interface
export interface MemoryQuizContextValue {
state: MemoryQuizState
isGameActive: boolean
isRoomCreator: boolean
resetGame: () => void
exitSession?: () => void
startQuiz: (quizCards: QuizCard[]) => void
nextCard: () => void
showInputPhase: () => void
acceptNumber: (number: number) => void
rejectNumber: () => void
setInput: (input: string) => void
showResults: () => void
setConfig: (
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: unknown
) => void
// Legacy dispatch for UI-only actions (to be migrated to local state)
dispatch: (action: unknown) => void
}
// Create context
const MemoryQuizContext = createContext<MemoryQuizContextValue | null>(null)
// Hook to use the context
export function useMemoryQuiz(): MemoryQuizContextValue {
const context = useContext(MemoryQuizContext)
if (!context) {
throw new Error('useMemoryQuiz must be used within MemoryQuizProvider')
}
return context
}
/**
* RoomMemoryQuizProvider - Provides context for room-based multiplayer mode
* MemoryQuizProvider - Unified provider for room-based multiplayer
*
* This provider uses useArcadeSession for network-synchronized gameplay.
* All state changes are sent as moves and validated on the server.
*/
export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
export function MemoryQuizProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active player IDs as array
const activePlayers = Array.from(activePlayerIds)
// LOCAL-ONLY state for current input (not synced over network)
// This prevents sending a network request for every keystroke
const [localCurrentInput, setLocalCurrentInput] = useState('')
// Merge saved game config from room with initialState
// Settings are scoped by game name to preserve settings when switching games
// Merge saved game config from room with default initial state
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null | undefined
if (!gameConfig) {
return initialState
const savedConfig = gameConfig?.['memory-quiz'] as Record<string, unknown> | null | undefined
// Default initial state
const defaultState: MemoryQuizState = {
cards: [],
quizCards: [],
correctAnswers: [],
currentCardIndex: 0,
displayTime: 2.0,
selectedCount: 5,
selectedDifficulty: 'easy',
foundNumbers: [],
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: 'cooperative',
numberFoundBy: {},
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
wrongGuessAnimations: [],
hasPhysicalKeyboard: null,
testingMode: false,
showOnScreenKeyboard: false,
}
// Get settings for this specific game (memory-quiz)
const savedConfig = gameConfig['memory-quiz'] as Record<string, any> | null | undefined
if (!savedConfig) {
return initialState
return defaultState
}
return {
...initialState,
// Restore settings from saved config
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
displayTime: savedConfig.displayTime ?? initialState.displayTime,
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
playMode: savedConfig.playMode ?? initialState.playMode,
...defaultState,
selectedCount:
(savedConfig.selectedCount as 2 | 5 | 8 | 12 | 15) ?? defaultState.selectedCount,
displayTime: (savedConfig.displayTime as number) ?? defaultState.displayTime,
selectedDifficulty:
(savedConfig.selectedDifficulty as MemoryQuizState['selectedDifficulty']) ??
defaultState.selectedDifficulty,
playMode: (savedConfig.playMode as 'cooperative' | 'competitive') ?? defaultState.playMode,
}
}, [roomData?.gameConfig])
// Arcade session integration WITH room sync
const {
state,
sendMove,
connected: _connected,
exitSession,
} = useArcadeSession<SorobanQuizState>({
// Arcade session integration
const { state, sendMove, exitSession } = useArcadeSession<MemoryQuizState>({
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
roomId: roomData?.id || undefined,
initialState: mergedInitialState,
applyMove: applyMoveOptimistically,
})
// Clear local input when game phase changes or when game resets
// Clear local input when game phase changes
useEffect(() => {
if (state.gamePhase !== 'input') {
setLocalCurrentInput('')
@@ -261,7 +312,7 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
}
}, [state.prefixAcceptanceTimeout])
// Detect state corruption/mismatch (e.g., game type mismatch between sessions)
// Detect state corruption
const hasStateCorruption =
!state.quizCards ||
!state.correctAnswers ||
@@ -271,32 +322,33 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
// Computed values
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
// Build player metadata from room data and player map
// Build player metadata
const buildPlayerMetadata = useCallback(() => {
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
const metadata = buildPlayerMetadataUtil(activePlayers, playerOwnership, players, viewerId)
const metadata = buildPlayerMetadataUtil(
activePlayers,
playerOwnership,
players,
viewerId || undefined
)
return metadata
}, [activePlayers, players, roomData, viewerId])
// Action creators - send moves to arcade session
// Action creators
const startQuiz = useCallback(
(quizCards: QuizCard[]) => {
// Extract only serializable data (numbers) for server
// React components can't be sent over Socket.IO
const numbers = quizCards.map((card) => card.number)
// Build player metadata for multiplayer
const playerMetadata = buildPlayerMetadata()
sendMove({
type: 'START_QUIZ',
playerId: TEAM_MOVE, // Team move - all players act together
userId: viewerId || '', // User who initiated
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {
numbers, // Send to server
quizCards, // Keep for optimistic local update
activePlayers, // Send active players list
playerMetadata, // Send player display info
numbers,
quizCards,
activePlayers,
playerMetadata,
},
})
},
@@ -323,13 +375,11 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const acceptNumber = useCallback(
(number: number) => {
// Clear local input immediately
setLocalCurrentInput('')
sendMove({
type: 'ACCEPT_NUMBER',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed correctly
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { number },
})
},
@@ -337,20 +387,17 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
)
const rejectNumber = useCallback(() => {
// Clear local input immediately
setLocalCurrentInput('')
sendMove({
type: 'REJECT_NUMBER',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed incorrectly
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const setInput = useCallback((input: string) => {
// LOCAL ONLY - no network sync!
// This makes typing instant with zero network lag
// LOCAL ONLY - no network sync for instant typing
setLocalCurrentInput(input)
}, [])
@@ -373,9 +420,10 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
}, [viewerId, sendMove])
const setConfig = useCallback(
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode', value: any) => {
console.log(`[RoomMemoryQuizProvider] setConfig called: ${field} = ${value}`)
(
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: unknown
) => {
sendMove({
type: 'SET_CONFIG',
playerId: TEAM_MOVE,
@@ -383,12 +431,11 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
data: { field, value },
})
// Save setting to room's gameConfig for persistence
// Settings are scoped by game name to preserve settings when switching games
// Save to room config for persistence
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
const currentMemoryQuizConfig =
(currentGameConfig['memory-quiz'] as Record<string, any>) || {}
(currentGameConfig['memory-quiz'] as Record<string, unknown>) || {}
updateGameConfig({
roomId: roomData.id,
@@ -405,13 +452,27 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
[viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
// Merge network state with local input state
// Legacy dispatch stub for UI-only actions
// TODO: Migrate these to local component state
const dispatch = useCallback((action: unknown) => {
console.warn(
'[MemoryQuizProvider] dispatch() is deprecated for UI-only actions. These should be migrated to local component state:',
action
)
// No-op - UI-only state changes should be handled locally
}, [])
// Merge network state with local input
const mergedState = {
...state,
currentInput: localCurrentInput, // Override network state with local input
currentInput: localCurrentInput,
}
// If state is corrupted, show error message instead of crashing
// Determine if current user is room creator
const isRoomCreator =
roomData?.members.find((member) => member.userId === viewerId)?.isCreator || false
// Handle state corruption
if (hasStateCorruption) {
return (
<div
@@ -425,68 +486,18 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
minHeight: '400px',
}}
>
<div
style={{
fontSize: '48px',
marginBottom: '20px',
}}
>
</div>
<div style={{ fontSize: '48px', marginBottom: '20px' }}></div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#dc2626',
}}
style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '12px', color: '#dc2626' }}
>
Game State Mismatch
</h2>
<p
style={{
fontSize: '16px',
color: '#6b7280',
marginBottom: '24px',
maxWidth: '500px',
}}
>
<p style={{ fontSize: '16px', color: '#6b7280', marginBottom: '24px', maxWidth: '500px' }}>
There's a mismatch between game types in this room. This usually happens when room members
are playing different games.
</p>
<div
style={{
background: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px',
maxWidth: '500px',
}}
>
<p
style={{
fontSize: '14px',
fontWeight: '600',
marginBottom: '8px',
}}
>
To fix this:
</p>
<ol
style={{
fontSize: '14px',
textAlign: 'left',
paddingLeft: '20px',
lineHeight: '1.6',
}}
>
<li>Make sure all room members are on the same game page</li>
<li>Try refreshing the page</li>
<li>If the issue persists, leave and rejoin the room</li>
</ol>
</div>
<button
type="button"
onClick={() => window.location.reload()}
style={{
padding: '10px 20px',
@@ -505,21 +516,12 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
)
}
// Determine if current user is the room creator (controls card timing)
const isRoomCreator =
roomData?.members.find((member) => member.userId === viewerId)?.isCreator || false
const contextValue: MemoryQuizContextValue = {
state: mergedState,
dispatch: () => {
// No-op - replaced with action creators
console.warn('dispatch() is deprecated in room mode, use action creators instead')
},
isGameActive,
resetGame,
exitSession,
isRoomCreator, // Pass room creator flag to components
// Expose action creators for components to use
isRoomCreator,
startQuiz,
nextCard,
showInputPhase,
@@ -528,10 +530,8 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
setInput,
showResults,
setConfig,
dispatch,
}
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
}
// Export the hook for this provider
export { useMemoryQuiz } from './MemoryQuizContext'

View File

@@ -3,21 +3,18 @@
* Validates all game moves and state transitions
*/
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
import type {
GameValidator,
MemoryQuizGameMove,
MemoryQuizConfig,
MemoryQuizState,
MemoryQuizMove,
MemoryQuizSetConfigMove,
ValidationResult,
} from './types'
export class MemoryQuizGameValidator
implements GameValidator<SorobanQuizState, MemoryQuizGameMove>
{
export class MemoryQuizGameValidator implements GameValidator<MemoryQuizState, MemoryQuizMove> {
validateMove(
state: SorobanQuizState,
move: MemoryQuizGameMove,
state: MemoryQuizState,
move: MemoryQuizMove,
context?: { userId?: string; playerOwnership?: Record<string, string> }
): ValidationResult {
switch (move.type) {
@@ -58,7 +55,7 @@ export class MemoryQuizGameValidator
}
}
private validateStartQuiz(state: SorobanQuizState, data: any): ValidationResult {
private validateStartQuiz(state: MemoryQuizState, data: any): ValidationResult {
// Can start quiz from setup or results phase
if (state.gamePhase !== 'setup' && state.gamePhase !== 'results') {
return {
@@ -102,7 +99,7 @@ export class MemoryQuizGameValidator
return acc
}, {})
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
quizCards,
correctAnswers: numbers,
@@ -127,7 +124,7 @@ export class MemoryQuizGameValidator
}
}
private validateNextCard(state: SorobanQuizState): ValidationResult {
private validateNextCard(state: MemoryQuizState): ValidationResult {
// Must be in display phase
if (state.gamePhase !== 'display') {
return {
@@ -136,7 +133,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
currentCardIndex: state.currentCardIndex + 1,
}
@@ -147,7 +144,7 @@ export class MemoryQuizGameValidator
}
}
private validateShowInputPhase(state: SorobanQuizState): ValidationResult {
private validateShowInputPhase(state: MemoryQuizState): ValidationResult {
// Must have shown all cards
if (state.currentCardIndex < state.quizCards.length) {
return {
@@ -156,7 +153,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
gamePhase: 'input',
}
@@ -168,7 +165,7 @@ export class MemoryQuizGameValidator
}
private validateAcceptNumber(
state: SorobanQuizState,
state: MemoryQuizState,
number: number,
userId?: string
): ValidationResult {
@@ -212,7 +209,7 @@ export class MemoryQuizGameValidator
newNumberFoundBy[number] = userId
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
foundNumbers: [...state.foundNumbers, number],
currentInput: '',
@@ -226,7 +223,7 @@ export class MemoryQuizGameValidator
}
}
private validateRejectNumber(state: SorobanQuizState, userId?: string): ValidationResult {
private validateRejectNumber(state: MemoryQuizState, userId?: string): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
@@ -254,7 +251,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
@@ -268,7 +265,7 @@ export class MemoryQuizGameValidator
}
}
private validateSetInput(state: SorobanQuizState, input: string): ValidationResult {
private validateSetInput(state: MemoryQuizState, input: string): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
@@ -285,7 +282,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
currentInput: input,
}
@@ -296,7 +293,7 @@ export class MemoryQuizGameValidator
}
}
private validateShowResults(state: SorobanQuizState): ValidationResult {
private validateShowResults(state: MemoryQuizState): ValidationResult {
// Can show results from input phase
if (state.gamePhase !== 'input') {
return {
@@ -305,7 +302,7 @@ export class MemoryQuizGameValidator
}
}
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
gamePhase: 'results',
}
@@ -316,9 +313,9 @@ export class MemoryQuizGameValidator
}
}
private validateResetQuiz(state: SorobanQuizState): ValidationResult {
private validateResetQuiz(state: MemoryQuizState): ValidationResult {
// Can reset from any phase
const newState: SorobanQuizState = {
const newState: MemoryQuizState = {
...state,
gamePhase: 'setup',
quizCards: [],
@@ -340,7 +337,7 @@ export class MemoryQuizGameValidator
}
private validateSetConfig(
state: SorobanQuizState,
state: MemoryQuizState,
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: any
): ValidationResult {
@@ -392,11 +389,11 @@ export class MemoryQuizGameValidator
}
}
isGameComplete(state: SorobanQuizState): boolean {
isGameComplete(state: MemoryQuizState): boolean {
return state.gamePhase === 'results'
}
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
getInitialState(config: MemoryQuizConfig): MemoryQuizState {
return {
cards: [],
quizCards: [],

View File

@@ -1,8 +1,8 @@
import { AbacusReact } from '@soroban/abacus-react'
import type { SorobanQuizState } from '../types'
import type { MemoryQuizState } from '../types'
interface CardGridProps {
state: SorobanQuizState
state: MemoryQuizState
}
export function CardGrid({ state }: CardGridProps) {

View File

@@ -1,6 +1,6 @@
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { useMemoryQuiz } from '../Provider'
import type { QuizCard } from '../types'
// Calculate maximum columns needed for a set of numbers

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { isPrefix } from '@/lib/memory-quiz-utils'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { useMemoryQuiz } from '../Provider'
import { CardGrid } from './CardGrid'
export function InputPhase() {

View File

@@ -3,8 +3,8 @@
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../../styled-system/css'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { css } from '../../../../styled-system/css'
import { useMemoryQuiz } from '../Provider'
import { DisplayPhase } from './DisplayPhase'
import { InputPhase } from './InputPhase'
import { ResultsPhase } from './ResultsPhase'

View File

@@ -1,8 +1,8 @@
import { AbacusReact } from '@soroban/abacus-react'
import type { SorobanQuizState } from '../types'
import type { MemoryQuizState } from '../types'
interface ResultsCardGridProps {
state: SorobanQuizState
state: MemoryQuizState
}
export function ResultsCardGrid({ state }: ResultsCardGridProps) {

View File

@@ -1,5 +1,5 @@
import { useAbacusConfig } from '@soroban/abacus-react'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { useMemoryQuiz } from '../Provider'
import { DIFFICULTY_LEVELS, type DifficultyLevel, type QuizCard } from '../types'
import { ResultsCardGrid } from './ResultsCardGrid'
@@ -384,7 +384,7 @@ export function ResultsPhase() {
// Group players by userId
const userTeams = new Map<
string,
{ userId: string; players: any[]; score: { correct: number; incorrect: 0 } }
{ userId: string; players: any[]; score: { correct: number; incorrect: number } }
>()
console.log('🤝 [ResultsPhase] Building team contributions:', {

View File

@@ -1,5 +1,5 @@
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { useMemoryQuiz } from '../Provider'
import { DIFFICULTY_LEVELS, type DifficultyLevel, type QuizCard } from '../types'
// Generate quiz cards with difficulty-based number ranges

View File

@@ -0,0 +1,85 @@
/**
* Memory Quiz (Memory Lightning) Game Definition
*
* A memory game where players memorize soroban numbers and recall them.
* Supports both cooperative and competitive multiplayer modes.
*/
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { MemoryQuizGame } from './components/MemoryQuizGame'
import { MemoryQuizProvider } from './Provider'
import type { MemoryQuizConfig, MemoryQuizMove, MemoryQuizState } from './types'
import { memoryQuizGameValidator } from './Validator'
const manifest: GameManifest = {
name: 'memory-quiz',
displayName: 'Memory Lightning',
icon: '🧠',
description: 'Memorize soroban numbers and recall them',
longDescription:
'Test your memory by studying soroban numbers for a brief time, then recall as many as you can. ' +
'Choose your difficulty level, number of cards, and display time. Play cooperatively with friends or compete for the highest score!',
maxPlayers: 8,
difficulty: 'Intermediate',
chips: ['👥 Multiplayer', '🧠 Memory', '🧮 Soroban'],
color: 'blue',
gradient: 'linear-gradient(135deg, #dbeafe, #bfdbfe)',
borderColor: 'blue.200',
available: true,
}
const defaultConfig: MemoryQuizConfig = {
selectedCount: 5,
displayTime: 2.0,
selectedDifficulty: 'easy',
playMode: 'cooperative',
}
// Config validation function
function validateMemoryQuizConfig(config: unknown): config is MemoryQuizConfig {
if (typeof config !== 'object' || config === null) {
return false
}
const c = config as any
// Validate selectedCount
if (!('selectedCount' in c) || ![2, 5, 8, 12, 15].includes(c.selectedCount)) {
return false
}
// Validate displayTime
if (
!('displayTime' in c) ||
typeof c.displayTime !== 'number' ||
c.displayTime < 0.5 ||
c.displayTime > 10
) {
return false
}
// Validate selectedDifficulty
if (
!('selectedDifficulty' in c) ||
!['beginner', 'easy', 'medium', 'hard', 'expert'].includes(c.selectedDifficulty)
) {
return false
}
// Validate playMode
if (!('playMode' in c) || !['cooperative', 'competitive'].includes(c.playMode)) {
return false
}
return true
}
export const memoryQuizGame = defineGame<MemoryQuizConfig, MemoryQuizState, MemoryQuizMove>({
manifest,
Provider: MemoryQuizProvider,
GameComponent: MemoryQuizGame,
validator: memoryQuizGameValidator,
defaultConfig,
validateConfig: validateMemoryQuizConfig,
})

View File

@@ -1,3 +1,4 @@
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk'
import type { PlayerMetadata } from '@/lib/arcade/player-ownership.client'
export interface QuizCard {
@@ -11,7 +12,16 @@ export interface PlayerScore {
incorrect: number
}
export interface SorobanQuizState {
// Memory Quiz Configuration
export interface MemoryQuizConfig extends GameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
}
// Memory Quiz State
export interface MemoryQuizState extends GameState {
// Core game data
cards: QuizCard[]
quizCards: QuizCard[]
@@ -52,6 +62,7 @@ export interface SorobanQuizState {
showOnScreenKeyboard: boolean
}
// Legacy reducer actions (deprecated - will be removed)
export type QuizAction =
| { type: 'SET_CARDS'; cards: QuizCard[] }
| { type: 'SET_DISPLAY_TIME'; time: number }
@@ -103,3 +114,79 @@ export const DIFFICULTY_LEVELS = {
} as const
export type DifficultyLevel = keyof typeof DIFFICULTY_LEVELS
// Memory Quiz Move Types (SDK-compatible)
export type MemoryQuizMove =
| {
type: 'START_QUIZ'
playerId: string
userId: string
timestamp: number
data: {
numbers: number[]
quizCards?: QuizCard[]
activePlayers: string[]
playerMetadata: Record<string, PlayerMetadata>
}
}
| {
type: 'NEXT_CARD'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SHOW_INPUT_PHASE'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'ACCEPT_NUMBER'
playerId: string
userId: string
timestamp: number
data: { number: number }
}
| {
type: 'REJECT_NUMBER'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SET_INPUT'
playerId: string
userId: string
timestamp: number
data: { input: string }
}
| {
type: 'SHOW_RESULTS'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'RESET_QUIZ'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SET_CONFIG'
playerId: string
userId: string
timestamp: number
data: {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
value: any
}
}
export type MemoryQuizSetConfigMove = Extract<MemoryQuizMove, { type: 'SET_CONFIG' }>

View File

@@ -34,6 +34,23 @@ const defaultConfig: NumberGuesserConfig = {
roundsToWin: 3,
}
// Config validation function
function validateNumberGuesserConfig(config: unknown): config is NumberGuesserConfig {
return (
typeof config === 'object' &&
config !== null &&
'minNumber' in config &&
'maxNumber' in config &&
'roundsToWin' in config &&
typeof config.minNumber === 'number' &&
typeof config.maxNumber === 'number' &&
typeof config.roundsToWin === 'number' &&
config.minNumber >= 1 &&
config.maxNumber > config.minNumber &&
config.roundsToWin >= 1
)
}
// Export game definition
export const numberGuesserGame = defineGame<
NumberGuesserConfig,
@@ -45,4 +62,5 @@ export const numberGuesserGame = defineGame<
GameComponent,
validator: numberGuesserValidator,
defaultConfig,
validateConfig: validateNumberGuesserConfig,
})

View File

@@ -8,21 +8,6 @@ import { GameCard } from './GameCard'
// Game configuration defining player limits
export const GAMES_CONFIG = {
'memory-quiz': {
name: 'Memory Lightning',
fullName: 'Memory Lightning ⚡',
maxPlayers: 4,
description: 'Test your memory speed with rapid-fire abacus calculations',
longDescription:
'Challenge yourself or compete with friends in lightning-fast memory tests. Work together cooperatively or compete for the highest score!',
url: '/arcade/memory-quiz',
icon: '⚡',
chips: ['👥 Multiplayer', '🔥 Speed Challenge', '🧮 Abacus Focus'],
color: 'green',
gradient: 'linear-gradient(135deg, #dcfce7, #bbf7d0)',
borderColor: 'green.200',
difficulty: 'Beginner',
},
'battle-arena': {
name: 'Matching Pairs Battle',
fullName: 'Matching Pairs Battle ⚔️',

View File

@@ -18,6 +18,22 @@ import {
DEFAULT_MATH_SPRINT_CONFIG,
} from './game-configs'
// Lazy-load game registry to avoid loading React components on server
function getGame(gameName: string) {
// Only load game registry in browser environment
// On server, we fall back to switch statement validation
if (typeof window !== 'undefined') {
try {
const { getGame: registryGetGame } = require('./game-registry')
return registryGetGame(gameName)
} catch (error) {
console.warn('[GameConfig] Failed to load game registry:', error)
return undefined
}
}
return undefined
}
/**
* Extended game name type that includes both registered validators and legacy games
* TODO: Remove 'complement-race' once migrated to the new modular system
@@ -176,8 +192,20 @@ 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
*
* NEW: Uses game registry validation functions instead of switch statements.
* Games now own their own validation logic!
*/
export function validateGameConfig(gameName: ExtendedGameName, config: any): boolean {
// Try to get game from registry
const game = getGame(gameName)
// If game has a validateConfig function, use it
if (game && game.validateConfig) {
return game.validateConfig(config)
}
// Fallback for legacy games without registry (e.g., complement-race, matching, memory-quiz)
switch (gameName) {
case 'matching':
return (
@@ -206,31 +234,8 @@ export function validateGameConfig(gameName: ExtendedGameName, config: any): boo
// 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
)
case 'math-sprint':
return (
typeof config === 'object' &&
config !== null &&
['easy', 'medium', 'hard'].includes(config.difficulty) &&
typeof config.questionsPerRound === 'number' &&
typeof config.timePerQuestion === 'number' &&
config.questionsPerRound >= 5 &&
config.questionsPerRound <= 20 &&
config.timePerQuestion >= 10
)
default:
return false
// If no validator found, accept any object
return typeof config === 'object' && config !== null
}
}

View File

@@ -1,7 +1,10 @@
/**
* Shared game configuration types
*
* This is the single source of truth for all game settings.
* ARCHITECTURE: Phase 3 - Type Inference
* - Modern games (number-guesser, math-sprint, memory-quiz): Types inferred from game definitions
* - Legacy games (matching, complement-race): Manual types until migrated
*
* These types are used across:
* - Database storage (room_game_configs table)
* - Validators (getInitialState method signatures)
@@ -9,9 +12,45 @@
* - Helper functions (reading/writing configs)
*/
import type { DifficultyLevel } from '@/app/arcade/memory-quiz/types'
import type { Difficulty, GameType } from '@/app/games/matching/context/types'
import type { Difficulty as MathSprintDifficulty } from '@/arcade-games/math-sprint/types'
// Type-only imports (won't load React components at runtime)
import type { numberGuesserGame } from '@/arcade-games/number-guesser'
import type { mathSprintGame } from '@/arcade-games/math-sprint'
import type { memoryQuizGame } from '@/arcade-games/memory-quiz'
/**
* Utility type: Extract config type from a game definition
* Uses TypeScript's infer keyword to extract the TConfig generic
*/
type InferGameConfig<T> = T extends { defaultConfig: infer Config } ? Config : never
// ============================================================================
// Modern Games (Type Inference from Game Definitions)
// ============================================================================
/**
* Configuration for number-guesser game
* INFERRED from numberGuesserGame.defaultConfig
*/
export type NumberGuesserGameConfig = InferGameConfig<typeof numberGuesserGame>
/**
* Configuration for math-sprint game
* INFERRED from mathSprintGame.defaultConfig
*/
export type MathSprintGameConfig = InferGameConfig<typeof mathSprintGame>
/**
* Configuration for memory-quiz (soroban lightning) game
* INFERRED from memoryQuizGame.defaultConfig
*/
export type MemoryQuizGameConfig = InferGameConfig<typeof memoryQuizGame>
// ============================================================================
// Legacy Games (Manual Type Definitions)
// TODO: Migrate these games to the modular system for type inference
// ============================================================================
/**
* Configuration for matching (memory pairs) game
@@ -22,16 +61,6 @@ export interface MatchingGameConfig {
turnTimer: number
}
/**
* Configuration for memory-quiz (soroban lightning) game
*/
export interface MemoryQuizGameConfig {
selectedCount: 2 | 5 | 8 | 12 | 15
displayTime: number
selectedDifficulty: DifficultyLevel
playMode: 'cooperative' | 'competitive'
}
/**
* Configuration for complement-race game
* TODO: Define when implementing complement-race settings
@@ -41,45 +70,33 @@ export interface ComplementRaceGameConfig {
placeholder?: never
}
/**
* Configuration for number-guesser game
*/
export interface NumberGuesserGameConfig {
minNumber: number
maxNumber: number
roundsToWin: number
}
/**
* Configuration for math-sprint game
*/
export interface MathSprintGameConfig {
difficulty: MathSprintDifficulty
questionsPerRound: number
timePerQuestion: number
}
// ============================================================================
// Combined Types
// ============================================================================
/**
* Union type of all game configs for type-safe access
* Modern games use inferred types, legacy games use manual types
*/
export type GameConfigByName = {
matching: MatchingGameConfig
'memory-quiz': MemoryQuizGameConfig
'complement-race': ComplementRaceGameConfig
// Modern games (inferred types)
'number-guesser': NumberGuesserGameConfig
'math-sprint': MathSprintGameConfig
'memory-quiz': MemoryQuizGameConfig
// Legacy games (manual types)
matching: MatchingGameConfig
'complement-race': ComplementRaceGameConfig
}
/**
* Room's game configuration object (nested by game name)
* This matches the structure stored in room_game_configs table
*
* AUTO-DERIVED: Adding a game to GameConfigByName automatically adds it here
*/
export interface RoomGameConfig {
matching?: MatchingGameConfig
'memory-quiz'?: MemoryQuizGameConfig
'complement-race'?: ComplementRaceGameConfig
'number-guesser'?: NumberGuesserGameConfig
'math-sprint'?: MathSprintGameConfig
export type RoomGameConfig = {
[K in keyof GameConfigByName]?: GameConfigByName[K]
}
/**

View File

@@ -108,6 +108,8 @@ export function clearRegistry(): void {
import { numberGuesserGame } from '@/arcade-games/number-guesser'
import { mathSprintGame } from '@/arcade-games/math-sprint'
import { memoryQuizGame } from '@/arcade-games/memory-quiz'
registerGame(numberGuesserGame)
registerGame(mathSprintGame)
registerGame(memoryQuizGame)

View File

@@ -36,6 +36,9 @@ export interface DefineGameOptions<
/** Default configuration for the game */
defaultConfig: TConfig
/** Optional: Runtime config validation function */
validateConfig?: (config: unknown) => config is TConfig
}
/**
@@ -63,7 +66,7 @@ export function defineGame<
TState extends GameState,
TMove extends GameMove,
>(options: DefineGameOptions<TConfig, TState, TMove>): GameDefinition<TConfig, TState, TMove> {
const { manifest, Provider, GameComponent, validator, defaultConfig } = options
const { manifest, Provider, GameComponent, validator, defaultConfig, validateConfig } = options
// Validate that manifest.name matches the game identifier
if (!manifest.name) {
@@ -76,5 +79,6 @@ export function defineGame<
GameComponent,
validator,
defaultConfig,
validateConfig,
}
}

View File

@@ -77,4 +77,13 @@ export interface GameDefinition<
/** Default configuration */
defaultConfig: TConfig
/**
* Validate a config object at runtime
* Returns true if config is valid for this game
*
* @param config - Configuration object to validate
* @returns true if valid, false otherwise
*/
validateConfig?: (config: unknown) => config is TConfig
}

View File

@@ -4,7 +4,7 @@
*/
import type { MemoryPairsState } from '@/app/games/matching/context/types'
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
import type { MemoryQuizState as SorobanQuizState } from '@/arcade-games/memory-quiz/types'
/**
* Game name type - auto-derived from validator registry

View File

@@ -11,7 +11,7 @@
*/
import { matchingGameValidator } from './validation/MatchingGameValidator'
import { memoryQuizGameValidator } from './validation/MemoryQuizGameValidator'
import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator'
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
import { mathSprintValidator } from '@/arcade-games/math-sprint/Validator'
import type { GameValidator } from './validation/types'

View File

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