Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1c40f1733 | ||
|
|
39485826fc | ||
|
|
7e2df106e6 | ||
|
|
99eee69f28 | ||
|
|
9952e11c27 | ||
|
|
f48c37accc | ||
|
|
704f34f83e |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,3 +1,23 @@
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
|
||||
## 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, or helper switch statements.
|
||||
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.
|
||||
|
||||
**Grade**: **A-** (Up from B- after improvements)
|
||||
**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)
|
||||
|
||||
---
|
||||
|
||||
@@ -87,25 +91,29 @@ export const mathSprintGame = defineGame({
|
||||
|
||||
### Adding a New Game
|
||||
|
||||
| Task | Before | After |
|
||||
|------|--------|-------|
|
||||
| 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** | Add to GameConfigByName + RoomGameConfig | Still needed (see Note below) |
|
||||
| **Default Config** | Add to DEFAULT_X_CONFIG constant | Still needed (see Note below) |
|
||||
| **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 → **6** (50% reduction)
|
||||
**Total Files to Update**: 12 → **3** (75% reduction)
|
||||
**Total Lines of Boilerplate**: ~60 lines → ~20 lines (67% reduction)
|
||||
|
||||
### What's Left
|
||||
|
||||
Two items still require manual updates:
|
||||
1. **Game Config Types** (`game-configs.ts`) - Type definitions
|
||||
2. **Default Config Constants** (`game-configs.ts`) - Shared defaults
|
||||
|
||||
These will be addressed in Phase 3 (Infer Config Types from Game Definitions).
|
||||
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!)
|
||||
|
||||
---
|
||||
|
||||
@@ -153,27 +161,55 @@ These will be addressed in Phase 3 (Infer Config Types from Game Definitions).
|
||||
|
||||
---
|
||||
|
||||
## Future Work (Optional)
|
||||
### 3. ✅ Config Type Inference (Phase 3)
|
||||
|
||||
### Phase 3: Infer Config Types from Game Definitions
|
||||
Still requires manual updates to `game-configs.ts`:
|
||||
- Game-specific config type definitions
|
||||
- Default config constants
|
||||
- GameConfigByName union type
|
||||
- RoomGameConfig interface
|
||||
**Problem**: Config types manually defined in `game-configs.ts`, requiring 10-15 lines per game.
|
||||
|
||||
**Recommendation**: Use TypeScript utility types to infer from game definitions.
|
||||
**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
|
||||
// Instead of manually defining:
|
||||
export interface MathSprintGameConfig { ... }
|
||||
// Type-only import (won't load React components)
|
||||
import type { mathSprintGame } from '@/arcade-games/math-sprint'
|
||||
|
||||
// Infer from game:
|
||||
export type MathSprintGameConfig = typeof mathSprintGame.defaultConfig
|
||||
// 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]
|
||||
}
|
||||
```
|
||||
|
||||
**Benefit**: Eliminate 15+ lines of boilerplate per game.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -217,36 +253,50 @@ export type MathSprintGameConfig = typeof mathSprintGame.defaultConfig
|
||||
|
||||
## Conclusion
|
||||
|
||||
The modular game system is now **significantly improved**:
|
||||
The modular game system is now **significantly improved across all three phases**:
|
||||
|
||||
**Before**:
|
||||
- Must update 12 files to add a game
|
||||
- Database migration required
|
||||
- Easy to forget a step
|
||||
- Scattered validation logic
|
||||
**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**:
|
||||
- Update 6 files to add a game (50% reduction)
|
||||
- No database migration
|
||||
- Validation is self-contained
|
||||
- Clear error messages
|
||||
**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**:
|
||||
- Phase 3: Infer config types from game definitions
|
||||
- Add comprehensive test suite
|
||||
- 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 solid enough to scale to dozens of games without becoming unmaintainable.
|
||||
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 in index.ts
|
||||
3. Register in `validators.ts` (1 line)
|
||||
4. Register in `game-registry.ts` (1 line)
|
||||
5. Add types to `game-configs.ts` (still needed - will be fixed in Phase 3)
|
||||
6. Add defaults to `game-configs.ts` (still needed - will be fixed in Phase 3)
|
||||
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, or helper switch statements.
|
||||
**That's it!** No database schemas, API endpoints, helper switch statements, or manual interface definitions.
|
||||
|
||||
**Total**: 3 files to update, ~20 lines of boilerplate
|
||||
|
||||
1070
apps/web/docs/GAME_MIGRATION_PLAYBOOK.md
Normal file
1070
apps/web/docs/GAME_MIGRATION_PLAYBOOK.md
Normal file
File diff suppressed because it is too large
Load Diff
502
apps/web/docs/MATCHING_PAIRS_MIGRATION_PLAN.md
Normal file
502
apps/web/docs/MATCHING_PAIRS_MIGRATION_PLAN.md
Normal 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`
|
||||
676
apps/web/docs/MEMORY_QUIZ_MIGRATION_PLAN.md
Normal file
676
apps/web/docs/MEMORY_QUIZ_MIGRATION_PLAN.md
Normal 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`
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'
|
||||
@@ -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: [],
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
@@ -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() {
|
||||
@@ -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'
|
||||
@@ -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) {
|
||||
@@ -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:', {
|
||||
@@ -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
|
||||
85
apps/web/src/arcade-games/memory-quiz/index.ts
Normal file
85
apps/web/src/arcade-games/memory-quiz/index.ts
Normal 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,
|
||||
})
|
||||
@@ -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' }>
|
||||
@@ -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 ⚔️',
|
||||
|
||||
@@ -201,7 +201,7 @@ export function validateGameConfig(gameName: ExtendedGameName, config: any): boo
|
||||
const game = getGame(gameName)
|
||||
|
||||
// If game has a validateConfig function, use it
|
||||
if (game?.validateConfig) {
|
||||
if (game && game.validateConfig) {
|
||||
return game.validateConfig(config)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Shared game configuration types
|
||||
*
|
||||
* ARCHITECTURE: Phase 3 - Type Inference
|
||||
* - Modern games (number-guesser, math-sprint): Types inferred from game definitions
|
||||
* - Legacy games (matching, memory-quiz, complement-race): Manual types until migrated
|
||||
* - 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)
|
||||
@@ -12,12 +12,12 @@
|
||||
* - Helper functions (reading/writing configs)
|
||||
*/
|
||||
|
||||
import type { DifficultyLevel } from '@/app/arcade/memory-quiz/types'
|
||||
import type { Difficulty, GameType } from '@/app/games/matching/context/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
|
||||
@@ -41,6 +41,12 @@ export type NumberGuesserGameConfig = InferGameConfig<typeof numberGuesserGame>
|
||||
*/
|
||||
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
|
||||
@@ -55,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
|
||||
@@ -83,14 +79,14 @@ export interface ComplementRaceGameConfig {
|
||||
* Modern games use inferred types, legacy games use manual types
|
||||
*/
|
||||
export type GameConfigByName = {
|
||||
// Legacy games (manual types)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.0.3",
|
||||
"version": "4.1.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user