Compare commits

...

12 Commits

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

### Features

* **arcade:** migrate matching pairs - phases 1-4 and 7 complete ([2a3af97](2a3af973f7))

### Bug Fixes

* resolve TypeScript errors in MemoryGrid and StandardGameLayout ([cabbc82](cabbc82195))

### Code Refactoring

* **matching:** migrate to modular game system ([e5c4a4b](e5c4a4bae0))
* **matching:** remove legacy battle-arena references ([c46a098](c46a098381))
2025-10-16 05:39:11 +00:00
Thomas Hallock
c46a098381 refactor(matching): remove legacy battle-arena references
Remove duplicate game entries by cleaning up legacy GAMES_CONFIG references.
Matching game now accessed exclusively through game registry.

- Removed battle-arena from GAMES_CONFIG
- Removed battle-arena from GAME_TYPE_TO_NAME mapping
- Removed battle-arena navigation logic

Fixes duplicate game entries in game selector.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 00:38:08 -05:00
Thomas Hallock
cabbc82195 fix: resolve TypeScript errors in MemoryGrid and StandardGameLayout
- Fix cardElement type error by converting undefined to null
- Fix className type error by properly concatenating CSS classes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 00:38:08 -05:00
Thomas Hallock
e5c4a4bae0 refactor(matching): migrate to modular game system
Completes the Matching Pairs Battle migration from legacy dual-location
architecture to the unified modular game SDK system.

## Summary of Changes

### Phase 1-4: Core Infrastructure
- Created modular game definition with `defineGame()` in `src/arcade-games/matching/index.ts`
- Registered game in arcade registry with proper type inference
- Consolidated types into SDK-compatible `MatchingConfig`, `MatchingState`, and `MatchingMove`
- Migrated and updated validator with new import paths

### Phase 5-6: Provider and Components
- Created unified `MatchingProvider` with proper context and `useMatching` hook
- Moved all 7 components from arcade location to `src/arcade-games/matching/components/`
- Updated all component imports to use absolute paths (@/) where applicable
- Fixed styled-system import paths for new directory structure

### Phase 7-8: Utilities and Cleanup
- Migrated utility functions (cardGeneration, matchValidation, gameScoring)
- **Deleted 32 legacy files** from `/src/app/arcade/matching/` and `/src/app/games/matching/`
- Updated room page to use registry pattern exclusively
- Fixed all import references across the codebase

## Breaking Changes
- Old routes `/arcade/matching` and `/games/matching` no longer exist
- Game now accessed exclusively through arcade room system at `/arcade/room`
- Legacy providers and contexts removed

## Migration Verification
- All TypeScript errors in new code resolved
- Only remaining errors are pre-existing (@soroban/abacus-react, complement-race)
- Components successfully moved and imports updated
- Game registry integration working correctly

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 00:38:08 -05:00
Thomas Hallock
2a3af973f7 feat(arcade): migrate matching pairs - phases 1-4 and 7 complete
Phases completed:
- Phase 1: Pre-migration audit (arcade version is canonical)
- Phase 2: Create modular game definition with registry
- Phase 3: Move and update validator to modular location
- Phase 4: Consolidate and move SDK-compatible types
- Phase 7: Move utility functions (cardGeneration, matchValidation, gameScoring)

Changes:
- Created /src/arcade-games/matching/ with game definition
- Registered matching game in game registry
- Added type inference for MatchingGameConfig
- Moved validator with updated imports to use local types
- Created SDK-compatible MatchingConfig, MatchingState, MatchingMove types
- Moved utils with updated import paths

Remaining:
- Phase 5: Create unified Provider
- Phase 6: Consolidate and move components
- Phase 8: Update routes and clean up legacy files

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 00:38:08 -05:00
semantic-release-bot
d1c40f1733 chore(release): 4.1.0 [skip ci]
## [4.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.3...v4.1.0) (2025-10-16)

### Features

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

### Code Refactoring

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

### Documentation

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

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

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

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

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

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

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

Fixes issue where Memory Lightning appeared twice in game selector.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 21:40:39 -05:00
74 changed files with 4221 additions and 7732 deletions

View File

@@ -1,3 +1,41 @@
## [4.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.1.0...v4.2.0) (2025-10-16)
### Features
* **arcade:** migrate matching pairs - phases 1-4 and 7 complete ([2a3af97](https://github.com/antialias/soroban-abacus-flashcards/commit/2a3af973f70ff07de30b38bbe1cdc549a971846f))
### Bug Fixes
* resolve TypeScript errors in MemoryGrid and StandardGameLayout ([cabbc82](https://github.com/antialias/soroban-abacus-flashcards/commit/cabbc821955d70f118630dc21a9fcbb6d340f278))
### Code Refactoring
* **matching:** migrate to modular game system ([e5c4a4b](https://github.com/antialias/soroban-abacus-flashcards/commit/e5c4a4bae078c69e632945730c61299f7062f4be))
* **matching:** remove legacy battle-arena references ([c46a098](https://github.com/antialias/soroban-abacus-flashcards/commit/c46a0983813c87d5e82a5aa32c48a10a49259b00))
## [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)

View File

@@ -79,7 +79,14 @@
"Bash(tsc:*)",
"Bash(tsc-alias:*)",
"Bash(npx tsc-alias:*)",
"Bash(timeout 20 pnpm run:*)"
"Bash(timeout 20 pnpm run:*)",
"Bash(find:*)",
"Bash(for:*)",
"Bash(tree:*)",
"Bash(do sed -i '' \"s|from ''../context/MemoryPairsContext''|from ''../Provider''|g\" \"$file\")",
"Bash(do sed -i '' \"s|from ''../../../../../styled-system/css''|from ''@/styled-system/css''|g\" \"$file\")",
"Bash(tee:*)",
"Bash(do sed -i '' \"s|from ''@/styled-system/css''|from ''../../../../styled-system/css''|g\" \"$file\")"
],
"deny": [],
"ask": []

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,299 @@
# Matching Pairs Battle - Pre-Migration Audit Results
**Date**: 2025-01-16
**Phase**: 1 - Pre-Migration Audit
**Status**: Complete ✅
---
## Executive Summary
**Canonical Location**: `/src/app/arcade/matching/` is clearly the more advanced, feature-complete version.
**Key Findings**:
- Arcade version has pause/resume, networked presence, better player ownership
- Utils are **identical** between locations (can use either)
- **ResultsPhase.tsx** needs manual merge (arcade layout + games Performance Analysis)
- **7 files** currently import from `/games/matching/` - must update during migration
---
## File-by-File Comparison
### Components
#### 1. GameCard.tsx
**Differences**: Arcade has helper function `getPlayerIndex()` to reduce code duplication
**Decision**: ✅ Use arcade version (better code organization)
#### 2. PlayerStatusBar.tsx
**Differences**:
- Arcade: Distinguishes "Your turn" vs "Their turn" based on player ownership
- Arcade: Uses `useViewerId()` for authorization
- Games: Shows only "Your turn" for all players
**Decision**: ✅ Use arcade version (more feature-complete)
#### 3. ResultsPhase.tsx
**Differences**:
- Arcade: Modern responsive layout, exits via `exitSession()` to `/arcade`
- Games: Has unique "Performance Analysis" section (strengths/improvements)
- Games: Simple navigation to `/games`
**Decision**: ⚠️ MERGE REQUIRED
- Keep arcade's layout, navigation, responsive design
- **Add** Performance Analysis section from games version (lines 245-317)
#### 4. SetupPhase.tsx
**Differences**:
- Arcade: Full pause/resume with config change warnings
- Arcade: Uses action creators (setGameType, setDifficulty, setTurnTimer)
- Arcade: Sophisticated "Resume Game" vs "Start Game" button logic
- Games: Simple dispatch pattern, no pause/resume
**Decision**: ✅ Use arcade version (much more advanced)
#### 5. EmojiPicker.tsx
**Differences**: None (files identical)
**Decision**: ✅ Use arcade version (same as games)
#### 6. GamePhase.tsx
**Differences**:
- Arcade: Passes hoverCard, viewerId, gameMode to MemoryGrid
- Arcade: `enableMultiplayerPresence={true}`
- Games: No multiplayer presence features
**Decision**: ✅ Use arcade version (has networked presence)
#### 7. MemoryPairsGame.tsx
**Differences**:
- Arcade: Provides onExitSession, onSetup, onNewGame callbacks
- Arcade: Uses router for navigation
- Games: Simple component with just gameName prop
**Decision**: ✅ Use arcade version (better integration)
### Utilities
#### 1. cardGeneration.ts
**Differences**: None (files identical)
**Decision**: ✅ Use arcade version (same as games)
#### 2. matchValidation.ts
**Differences**: None (files identical)
**Decision**: ✅ Use arcade version (same as games)
#### 3. gameScoring.ts
**Differences**: None (files identical)
**Decision**: ✅ Use arcade version (same as games)
### Context/Types
#### types.ts
**Differences**:
- Arcade: PlayerMetadata properly typed (vs `any` in games)
- Arcade: Better documentation for pause/resume state
- Arcade: Hover state not optional (`playerHovers: {}` vs `playerHovers?: {}`)
- Arcade: More complete MemoryPairsContextValue interface
**Decision**: ✅ Use arcade version (better types)
---
## External Dependencies on `/games/matching/`
Found **7 imports** that reference `/games/matching/`:
1. `/src/components/nav/PlayerConfigDialog.tsx`
- Imports: `EmojiPicker`
- **Action**: Update to `@/arcade-games/matching/components/EmojiPicker`
2. `/src/lib/arcade/game-configs.ts`
- Imports: `Difficulty, GameType` types
- **Action**: Update to `@/arcade-games/matching/types`
3. `/src/lib/arcade/__tests__/arcade-session-integration.test.ts`
- Imports: `MemoryPairsState` type
- **Action**: Update to `@/arcade-games/matching/types`
4. `/src/lib/arcade/validation/MatchingGameValidator.ts` (3 imports)
- Imports: `GameCard, MemoryPairsState, Player` types
- Imports: `generateGameCards` util
- Imports: `canFlipCard, validateMatch` utils
- **Action**: Will be moved to `/src/arcade-games/matching/Validator.ts` in Phase 3
- Update imports to local `./types` and `./utils/*`
---
## Migration Strategy
### Canonical Source
**Use**: `/src/app/arcade/matching/` as the base for all files
**Exception**: Merge Performance Analysis from `/src/app/games/matching/components/ResultsPhase.tsx`
### Files to Move (from `/src/app/arcade/matching/`)
**Components** (7 files):
- ✅ GameCard.tsx (as-is)
- ✅ PlayerStatusBar.tsx (as-is)
- ⚠️ ResultsPhase.tsx (merge with games version)
- ✅ SetupPhase.tsx (as-is)
- ✅ EmojiPicker.tsx (as-is)
- ✅ GamePhase.tsx (as-is)
- ✅ MemoryPairsGame.tsx (as-is)
**Utils** (3 files):
- ✅ cardGeneration.ts (as-is)
- ✅ matchValidation.ts (as-is)
- ✅ gameScoring.ts (as-is)
**Context**:
- ✅ types.ts (as-is)
- ✅ RoomMemoryPairsProvider.tsx (convert to modular Provider)
**Tests**:
- ✅ EmojiPicker.test.tsx
- ✅ playerMetadata-userId.test.ts
### Files to Delete (after migration)
**From `/src/app/arcade/matching/`** (~13 files):
- Components: 7 files + 1 test (move, then delete old location)
- Context: LocalMemoryPairsProvider.tsx, MemoryPairsContext.tsx, index.ts
- Utils: 3 files (move, then delete old location)
- page.tsx (replace with redirect)
**From `/src/app/games/matching/`** (~14 files):
- Components: 7 files + 2 tests (delete)
- Context: 2 files (delete)
- Utils: 3 files (delete)
- page.tsx (replace with redirect)
**Validator**:
- `/src/lib/arcade/validation/MatchingGameValidator.ts` (move to modular location)
**Total files to delete**: ~27 files
---
## Special Merge: ResultsPhase.tsx
### Keep from Arcade Version
- Responsive layout (padding, fontSize with base/md breakpoints)
- Modern stat cards design
- exitSession() navigation to /arcade
- Better button styling with gradients
### Add from Games Version
Lines 245-317: Performance Analysis section
```tsx
{/* Performance Analysis */}
<div className={css({
background: 'rgba(248, 250, 252, 0.8)',
padding: '30px',
borderRadius: '16px',
marginBottom: '40px',
border: '1px solid rgba(226, 232, 240, 0.8)',
maxWidth: '600px',
margin: '0 auto 40px auto',
})}>
<h3 className={css({
fontSize: '24px',
marginBottom: '20px',
color: 'gray.800',
})}>
Performance Analysis
</h3>
{analysis.strengths.length > 0 && (
<div className={css({ marginBottom: '20px' })}>
<h4 className={css({
fontSize: '18px',
color: 'green.600',
marginBottom: '8px',
})}>
Strengths:
</h4>
<ul className={css({
textAlign: 'left',
color: 'gray.700',
lineHeight: '1.6',
})}>
{analysis.strengths.map((strength, index) => (
<li key={index}>{strength}</li>
))}
</ul>
</div>
)}
{analysis.improvements.length > 0 && (
<div>
<h4 className={css({
fontSize: '18px',
color: 'orange.600',
marginBottom: '8px',
})}>
💡 Areas for Improvement:
</h4>
<ul className={css({
textAlign: 'left',
color: 'gray.700',
lineHeight: '1.6',
})}>
{analysis.improvements.map((improvement, index) => (
<li key={index}>{improvement}</li>
))}
</ul>
</div>
)}
</div>
```
**Note**: Need to ensure `analysis` variable is computed (may already exist in arcade version from `analyzePerformance` utility)
---
## Validator Assessment
**Location**: `/src/lib/arcade/validation/MatchingGameValidator.ts`
**Status**: ✅ Comprehensive and complete (570 lines)
**Handles all move types**:
- FLIP_CARD (with turn validation, player ownership)
- START_GAME
- CLEAR_MISMATCH
- GO_TO_SETUP (with pause state)
- SET_CONFIG (with validation)
- RESUME_GAME (with config change detection)
- HOVER_CARD (networked presence)
**Ready for migration**: Yes, just needs import path updates
---
## Next Steps (Phase 2)
1. Create `/src/arcade-games/matching/index.ts` with game definition
2. Register in game registry
3. Add type inference to game-configs.ts
4. Update validator imports
---
## Risks Identified
### Risk 1: Performance Analysis Feature Loss
**Mitigation**: Must manually merge Performance Analysis from games/ResultsPhase.tsx
### Risk 2: Import References
**Mitigation**: 7 files import from games/matching - systematic update required
### Risk 3: Test Coverage
**Mitigation**: Move tests with components, verify they still pass
---
## Conclusion
Phase 1 audit complete. Clear path forward:
- **Arcade version is canonical** for all files
- **Utils are identical** - no conflicts
- **One manual merge required** (ResultsPhase Performance Analysis)
- **7 import updates required** before deletion
Ready to proceed to Phase 2: Create Modular Game Definition.

View File

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

View File

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

View File

@@ -1,176 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { PLAYER_EMOJIS } from '../../../../../constants/playerEmojis'
import { EmojiPicker } from '../EmojiPicker'
// Mock the emoji keywords function for testing
vi.mock('emojibase-data/en/data.json', () => ({
default: [
{
emoji: '🐱',
label: 'cat face',
tags: ['cat', 'animal', 'pet', 'cute'],
emoticon: ':)',
},
{
emoji: '🐯',
label: 'tiger face',
tags: ['tiger', 'animal', 'big cat', 'wild'],
emoticon: null,
},
{
emoji: '🤩',
label: 'star-struck',
tags: ['face', 'happy', 'excited', 'star'],
emoticon: null,
},
{
emoji: '🎭',
label: 'performing arts',
tags: ['theater', 'performance', 'drama', 'arts'],
emoticon: null,
},
],
}))
describe('EmojiPicker Search Functionality', () => {
const mockProps = {
currentEmoji: '😀',
onEmojiSelect: vi.fn(),
onClose: vi.fn(),
playerNumber: 1 as const,
}
beforeEach(() => {
vi.clearAllMocks()
})
test('shows all emojis by default (no search)', () => {
render(<EmojiPicker {...mockProps} />)
// Should show default header
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
// Should show emoji count
expect(
screen.getByText(new RegExp(`${PLAYER_EMOJIS.length} characters available`))
).toBeInTheDocument()
// Should show emoji grid
const emojiButtons = screen
.getAllByRole('button')
.filter(
(button) =>
button.textContent &&
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
button.textContent
)
)
expect(emojiButtons.length).toBe(PLAYER_EMOJIS.length)
})
test('shows search results when searching for "cat"', () => {
render(<EmojiPicker {...mockProps} />)
const searchInput = screen.getByPlaceholderText(/Search:/)
fireEvent.change(searchInput, { target: { value: 'cat' } })
// Should show search header
expect(screen.getByText(/🔍 Search Results for "cat"/)).toBeInTheDocument()
// Should show results count
expect(screen.getByText(/✓ \d+ found/)).toBeInTheDocument()
// Should only show cat-related emojis (🐱, 🐯)
const emojiButtons = screen
.getAllByRole('button')
.filter(
(button) =>
button.textContent &&
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
button.textContent
)
)
// Verify only cat emojis are shown
const displayedEmojis = emojiButtons.map((btn) => btn.textContent)
expect(displayedEmojis).toContain('🐱')
expect(displayedEmojis).toContain('🐯')
expect(displayedEmojis).not.toContain('🤩')
expect(displayedEmojis).not.toContain('🎭')
})
test('shows no results message when search has zero matches', () => {
render(<EmojiPicker {...mockProps} />)
const searchInput = screen.getByPlaceholderText(/Search:/)
fireEvent.change(searchInput, { target: { value: 'nonexistentterm' } })
// Should show no results indicator
expect(screen.getByText('✗ No matches')).toBeInTheDocument()
// Should show no results message
expect(screen.getByText(/No emojis found for "nonexistentterm"/)).toBeInTheDocument()
// Should NOT show any emoji buttons
const emojiButtons = screen
.queryAllByRole('button')
.filter(
(button) =>
button.textContent &&
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
button.textContent
)
)
expect(emojiButtons).toHaveLength(0)
})
test('returns to default view when clearing search', () => {
render(<EmojiPicker {...mockProps} />)
const searchInput = screen.getByPlaceholderText(/Search:/)
// Search for something
fireEvent.change(searchInput, { target: { value: 'cat' } })
expect(screen.getByText(/🔍 Search Results for "cat"/)).toBeInTheDocument()
// Clear search
fireEvent.change(searchInput, { target: { value: '' } })
// Should return to default view
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
expect(
screen.getByText(new RegExp(`${PLAYER_EMOJIS.length} characters available`))
).toBeInTheDocument()
// Should show all emojis again
const emojiButtons = screen
.getAllByRole('button')
.filter(
(button) =>
button.textContent &&
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
button.textContent
)
)
expect(emojiButtons.length).toBe(PLAYER_EMOJIS.length)
})
test('clear search button works from no results state', () => {
render(<EmojiPicker {...mockProps} />)
const searchInput = screen.getByPlaceholderText(/Search:/)
// Search for something with no results
fireEvent.change(searchInput, { target: { value: 'nonexistentterm' } })
expect(screen.getByText(/No emojis found/)).toBeInTheDocument()
// Click clear search button
const clearButton = screen.getByText(/Clear search to see all/)
fireEvent.click(clearButton)
// Should return to default view
expect(searchInput).toHaveValue('')
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
})
})

View File

@@ -1,349 +0,0 @@
'use client'
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo } from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import type { GameMove } from '@/lib/arcade/validation'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { generateGameCards } from '../utils/cardGeneration'
import type { GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
// Initial state
const initialState: MemoryPairsState = {
cards: [],
gameCards: [],
flippedCards: [],
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
gamePhase: 'setup',
currentPlayer: '', // Will be set to first player ID on START_GAME
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: [],
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
}
/**
* Optimistic move application (client-side prediction)
* The server will validate and send back the authoritative state
*/
function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): MemoryPairsState {
switch (move.type) {
case 'START_GAME':
// Generate cards and initialize game
return {
...state,
gamePhase: 'playing',
gameCards: move.data.cards,
cards: move.data.cards,
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: move.data.activePlayers.reduce(
(acc: any, p: string) => ({ ...acc, [p]: 0 }),
{}
),
activePlayers: move.data.activePlayers,
currentPlayer: move.data.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
}
case 'FLIP_CARD': {
// Optimistically flip the card
const card = state.gameCards.find((c) => c.id === move.data.cardId)
if (!card) return state
const newFlippedCards = [...state.flippedCards, card]
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime:
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
showMismatchFeedback: false,
}
}
case 'CLEAR_MISMATCH': {
// Clear mismatched cards and feedback
return {
...state,
flippedCards: [],
showMismatchFeedback: false,
isProcessingMove: false,
}
}
default:
return state
}
}
// Create context
const ArcadeMemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
// Provider component
export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
// Arcade session integration with room-wide sync
const {
state,
sendMove,
connected: _connected,
exitSession,
} = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: roomData?.id, // Enable multi-user sync for room-based games
initialState,
applyMove: applyMoveOptimistically,
})
// Handle mismatch feedback timeout
useEffect(() => {
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
// After 1.5 seconds, clear the flipped cards and feedback
const timeout = setTimeout(() => {
sendMove({
type: 'CLEAR_MISMATCH',
playerId: state.currentPlayer, // Use current player ID for CLEAR_MISMATCH
data: {},
})
}, 1500)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const { players } = useGameMode()
const canFlipCard = useCallback(
(cardId: string): boolean => {
console.log('[canFlipCard] Checking card:', {
cardId,
isGameActive,
isProcessingMove: state.isProcessingMove,
currentPlayer: state.currentPlayer,
hasRoomData: !!roomData,
flippedCardsCount: state.flippedCards.length,
})
if (!isGameActive || state.isProcessingMove) {
console.log('[canFlipCard] Blocked: game not active or processing')
return false
}
const card = state.gameCards.find((c) => c.id === cardId)
if (!card || card.matched) {
console.log('[canFlipCard] Blocked: card not found or already matched')
return false
}
// Can't flip if already flipped
if (state.flippedCards.some((c) => c.id === cardId)) {
console.log('[canFlipCard] Blocked: card already flipped')
return false
}
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) {
console.log('[canFlipCard] Blocked: 2 cards already flipped')
return false
}
// Authorization check: Only allow flipping if it's your player's turn
if (roomData && state.currentPlayer) {
const currentPlayerData = players.get(state.currentPlayer)
console.log('[canFlipCard] Authorization check:', {
currentPlayerId: state.currentPlayer,
currentPlayerFound: !!currentPlayerData,
currentPlayerIsLocal: currentPlayerData?.isLocal,
})
// Block if current player is explicitly marked as remote (isLocal === false)
if (currentPlayerData && currentPlayerData.isLocal === false) {
console.log('[canFlipCard] BLOCKED: Current player is remote (not your turn)')
return false
}
// If player data not found in map, this might be an issue - allow for now but warn
if (!currentPlayerData) {
console.warn(
'[canFlipCard] WARNING: Current player not found in players map, allowing move'
)
}
}
console.log('[canFlipCard] ALLOWED: All checks passed')
return true
},
[
isGameActive,
state.isProcessingMove,
state.gameCards,
state.flippedCards,
state.currentPlayer,
roomData,
players,
]
)
const currentGameStatistics: GameStatistics = useMemo(
() => ({
totalMoves: state.moves,
matchedPairs: state.matchedPairs,
totalPairs: state.totalPairs,
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
averageTimePerMove:
state.moves > 0 && state.gameStartTime
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
: 0,
}),
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
)
// Action creators - send moves to arcade session
const startGame = useCallback(() => {
// Must have at least one active player
if (activePlayers.length === 0) {
console.error('[ArcadeMemoryPairs] Cannot start game without active players')
return
}
const cards = generateGameCards(state.gameType, state.difficulty)
// Use first active player as playerId for START_GAME move
const firstPlayer = activePlayers[0]
sendMove({
type: 'START_GAME',
playerId: firstPlayer,
data: {
cards,
activePlayers,
},
})
}, [state.gameType, state.difficulty, activePlayers, sendMove, roomData])
const flipCard = useCallback(
(cardId: string) => {
console.log('[Client] flipCard called:', {
cardId,
viewerId,
currentPlayer: state.currentPlayer,
activePlayers: state.activePlayers,
gamePhase: state.gamePhase,
canFlip: canFlipCard(cardId),
})
if (!canFlipCard(cardId)) {
console.log('[Client] Cannot flip card - canFlipCard returned false')
return
}
const move = {
type: 'FLIP_CARD' as const,
playerId: state.currentPlayer, // Use the current player ID from game state (database player ID)
data: { cardId },
}
console.log('[Client] Sending FLIP_CARD move via sendMove:', move)
sendMove(move)
},
[canFlipCard, sendMove, viewerId, state.currentPlayer, state.activePlayers, state.gamePhase]
)
const resetGame = useCallback(() => {
// Must have at least one active player
if (activePlayers.length === 0) {
console.error('[ArcadeMemoryPairs] Cannot reset game without active players')
return
}
// Delete current session and start a new game
const cards = generateGameCards(state.gameType, state.difficulty)
// Use first active player as playerId for START_GAME move
const firstPlayer = activePlayers[0]
sendMove({
type: 'START_GAME',
playerId: firstPlayer,
data: {
cards,
activePlayers,
},
})
}, [state.gameType, state.difficulty, activePlayers, sendMove])
const setGameType = useCallback((_gameType: typeof state.gameType) => {
// TODO: Implement via arcade session if needed
console.warn('setGameType not yet implemented for arcade mode')
}, [])
const setDifficulty = useCallback((_difficulty: typeof state.difficulty) => {
// TODO: Implement via arcade session if needed
console.warn('setDifficulty not yet implemented for arcade mode')
}, [])
const contextValue: MemoryPairsContextValue = {
state: { ...state, gameMode },
dispatch: () => {
// No-op - replaced with sendMove
console.warn('dispatch() is deprecated in arcade mode, use action creators instead')
},
isGameActive,
canFlipCard,
currentGameStatistics,
startGame,
flipCard,
resetGame,
setGameType,
setDifficulty,
exitSession,
gameMode,
activePlayers,
}
return (
<ArcadeMemoryPairsContext.Provider value={contextValue}>
{children}
</ArcadeMemoryPairsContext.Provider>
)
}
// Hook to use the context
export function useArcadeMemoryPairs(): MemoryPairsContextValue {
const context = useContext(ArcadeMemoryPairsContext)
if (!context) {
throw new Error('useArcadeMemoryPairs must be used within an ArcadeMemoryPairsProvider')
}
return context
}

View File

@@ -1,587 +0,0 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo, useReducer } from 'react'
import { useRouter } from 'next/navigation'
import { useViewerId } from '@/hooks/useViewerId'
import { useUserPlayers } from '@/hooks/useUserPlayers'
import { generateGameCards } from '../utils/cardGeneration'
import { validateMatch } from '../utils/matchValidation'
import { MemoryPairsContext } from './MemoryPairsContext'
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
// Initial state for local-only games
const initialState: MemoryPairsState = {
cards: [],
gameCards: [],
flippedCards: [],
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
gamePhase: 'setup',
currentPlayer: '',
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
playerHovers: {},
}
// Action types for local reducer
type LocalAction =
| {
type: 'START_GAME'
cards: any[]
activePlayers: string[]
playerMetadata: any
}
| { type: 'FLIP_CARD'; cardId: string }
| { type: 'MATCH_FOUND'; cardIds: [string, string]; playerId: string }
| { type: 'MATCH_FAILED'; cardIds: [string, string] }
| { type: 'CLEAR_MISMATCH' }
| { type: 'SWITCH_PLAYER' }
| { type: 'GO_TO_SETUP' }
| { type: 'SET_CONFIG'; field: string; value: any }
| { type: 'RESUME_GAME' }
| { type: 'HOVER_CARD'; playerId: string; cardId: string | null }
| { type: 'END_GAME' }
// Pure client-side reducer with complete game logic
function localMemoryPairsReducer(state: MemoryPairsState, action: LocalAction): MemoryPairsState {
switch (action.type) {
case 'START_GAME':
return {
...state,
gamePhase: 'playing',
gameCards: action.cards,
cards: action.cards,
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: action.activePlayers.reduce(
(acc: any, p: string) => ({ ...acc, [p]: 0 }),
{}
),
activePlayers: action.activePlayers,
playerMetadata: action.playerMetadata,
currentPlayer: action.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
originalConfig: {
gameType: state.gameType,
difficulty: state.difficulty,
turnTimer: state.turnTimer,
},
pausedGamePhase: undefined,
pausedGameState: undefined,
}
case 'FLIP_CARD': {
const card = state.gameCards.find((c) => c.id === action.cardId)
if (!card) return state
const newFlippedCards = [...state.flippedCards, card]
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime:
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2,
showMismatchFeedback: false,
}
}
case 'MATCH_FOUND': {
const [id1, id2] = action.cardIds
const updatedCards = state.gameCards.map((card) =>
card.id === id1 || card.id === id2
? { ...card, matched: true, matchedBy: action.playerId }
: card
)
const newMatchedPairs = state.matchedPairs + 1
const newScores = {
...state.scores,
[action.playerId]: (state.scores[action.playerId] || 0) + 1,
}
const newConsecutiveMatches = {
...state.consecutiveMatches,
[action.playerId]: (state.consecutiveMatches[action.playerId] || 0) + 1,
}
// Check if game is complete
const gameComplete = newMatchedPairs >= state.totalPairs
return {
...state,
gameCards: updatedCards,
cards: updatedCards,
flippedCards: [],
matchedPairs: newMatchedPairs,
moves: state.moves + 1,
scores: newScores,
consecutiveMatches: newConsecutiveMatches,
lastMatchedPair: action.cardIds,
isProcessingMove: false,
showMismatchFeedback: false,
gamePhase: gameComplete ? 'results' : state.gamePhase,
gameEndTime: gameComplete ? Date.now() : null,
// Player keeps their turn on match
}
}
case 'MATCH_FAILED': {
// Reset consecutive matches for current player
const newConsecutiveMatches = {
...state.consecutiveMatches,
[state.currentPlayer]: 0,
}
return {
...state,
moves: state.moves + 1,
showMismatchFeedback: true,
isProcessingMove: true,
consecutiveMatches: newConsecutiveMatches,
// Don't clear flipped cards yet - CLEAR_MISMATCH will do that
}
}
case 'CLEAR_MISMATCH': {
// Clear hover for all non-current players
const clearedHovers = { ...state.playerHovers }
for (const playerId of state.activePlayers) {
if (playerId !== state.currentPlayer) {
clearedHovers[playerId] = null
}
}
return {
...state,
flippedCards: [],
showMismatchFeedback: false,
isProcessingMove: false,
// Clear hovers for non-current players
playerHovers: clearedHovers,
}
}
case 'SWITCH_PLAYER': {
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
const nextIndex = (currentIndex + 1) % state.activePlayers.length
const nextPlayer = state.activePlayers[nextIndex]
return {
...state,
currentPlayer: nextPlayer,
currentMoveStartTime: Date.now(),
}
}
case 'GO_TO_SETUP': {
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
return {
...state,
gamePhase: 'setup',
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
pausedGameState: isPausingGame
? {
gameCards: state.gameCards,
currentPlayer: state.currentPlayer,
matchedPairs: state.matchedPairs,
moves: state.moves,
scores: state.scores,
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata || {},
consecutiveMatches: state.consecutiveMatches,
gameStartTime: state.gameStartTime,
}
: undefined,
gameCards: [],
cards: [],
flippedCards: [],
currentPlayer: '',
matchedPairs: 0,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
}
}
case 'SET_CONFIG': {
const clearPausedGame = !!state.pausedGamePhase
return {
...state,
[action.field]: action.value,
...(action.field === 'difficulty' ? { totalPairs: action.value } : {}),
...(clearPausedGame
? {
pausedGamePhase: undefined,
pausedGameState: undefined,
originalConfig: undefined,
}
: {}),
}
}
case 'RESUME_GAME': {
if (!state.pausedGamePhase || !state.pausedGameState) {
return state
}
return {
...state,
gamePhase: state.pausedGamePhase,
gameCards: state.pausedGameState.gameCards,
cards: state.pausedGameState.gameCards,
currentPlayer: state.pausedGameState.currentPlayer,
matchedPairs: state.pausedGameState.matchedPairs,
moves: state.pausedGameState.moves,
scores: state.pausedGameState.scores,
activePlayers: state.pausedGameState.activePlayers,
playerMetadata: state.pausedGameState.playerMetadata,
consecutiveMatches: state.pausedGameState.consecutiveMatches,
gameStartTime: state.pausedGameState.gameStartTime,
pausedGamePhase: undefined,
pausedGameState: undefined,
}
}
case 'HOVER_CARD': {
return {
...state,
playerHovers: {
...state.playerHovers,
[action.playerId]: action.cardId,
},
}
}
case 'END_GAME': {
return {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
}
}
default:
return state
}
}
// Provider component for LOCAL-ONLY play (no network, no arcade session)
export function LocalMemoryPairsProvider({ children }: { children: ReactNode }) {
const router = useRouter()
const { data: viewerId } = useViewerId()
// LOCAL-ONLY: Get only the current user's players (no room members)
const { data: userPlayers = [] } = useUserPlayers()
// Build players map from current user's players only
const players = useMemo(() => {
const map = new Map()
userPlayers.forEach((player) => {
map.set(player.id, {
id: player.id,
name: player.name,
emoji: player.emoji,
color: player.color,
isLocal: true,
})
})
return map
}, [userPlayers])
// Get active player IDs from current user's players only
const activePlayers = useMemo(() => {
return userPlayers.filter((p) => p.isActive).map((p) => p.id)
}, [userPlayers])
// Derive game mode from active player count
const gameMode = activePlayers.length > 1 ? 'multiplayer' : 'single'
// Pure client-side state with useReducer
const [state, dispatch] = useReducer(localMemoryPairsReducer, initialState)
// Handle mismatch feedback timeout and player switching
useEffect(() => {
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
const timeout = setTimeout(() => {
dispatch({ type: 'CLEAR_MISMATCH' })
// Switch to next player after mismatch
dispatch({ type: 'SWITCH_PLAYER' })
}, 1500)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback, state.flippedCards.length])
// Handle automatic match checking when 2 cards flipped
useEffect(() => {
if (state.flippedCards.length === 2 && !state.showMismatchFeedback) {
const [card1, card2] = state.flippedCards
const isMatch = validateMatch(card1, card2)
const timeout = setTimeout(() => {
if (isMatch.isValid) {
dispatch({
type: 'MATCH_FOUND',
cardIds: [card1.id, card2.id],
playerId: state.currentPlayer,
})
// Player keeps turn on match - no SWITCH_PLAYER
} else {
dispatch({
type: 'MATCH_FAILED',
cardIds: [card1.id, card2.id],
})
// SWITCH_PLAYER will happen after CLEAR_MISMATCH timeout
}
}, 600) // Small delay to show both cards
return () => clearTimeout(timeout)
}
}, [state.flippedCards, state.showMismatchFeedback, state.currentPlayer])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = useCallback(
(cardId: string): boolean => {
if (!isGameActive || state.isProcessingMove) {
return false
}
const card = state.gameCards.find((c) => c.id === cardId)
if (!card || card.matched) {
return false
}
if (state.flippedCards.some((c) => c.id === cardId)) {
return false
}
if (state.flippedCards.length >= 2) {
return false
}
// In local play, all local players can flip during their turn
const currentPlayerData = players.get(state.currentPlayer)
if (currentPlayerData && currentPlayerData.isLocal === false) {
return false
}
return true
},
[
isGameActive,
state.isProcessingMove,
state.gameCards,
state.flippedCards,
state.currentPlayer,
players,
]
)
const currentGameStatistics: GameStatistics = useMemo(
() => ({
totalMoves: state.moves,
matchedPairs: state.matchedPairs,
totalPairs: state.totalPairs,
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
averageTimePerMove:
state.moves > 0 && state.gameStartTime
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
: 0,
}),
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
)
const hasConfigChanged = useMemo(() => {
if (!state.originalConfig) return false
return (
state.gameType !== state.originalConfig.gameType ||
state.difficulty !== state.originalConfig.difficulty ||
state.turnTimer !== state.originalConfig.turnTimer
)
}, [state.gameType, state.difficulty, state.turnTimer, state.originalConfig])
const canResumeGame = useMemo(() => {
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
// Action creators
const startGame = useCallback(() => {
if (activePlayers.length === 0) {
console.error('[LocalMemoryPairs] Cannot start game without active players')
return
}
const playerMetadata: { [playerId: string]: any } = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
if (playerData) {
playerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: viewerId || '',
color: playerData.color,
}
}
}
const cards = generateGameCards(state.gameType, state.difficulty)
dispatch({
type: 'START_GAME',
cards,
activePlayers,
playerMetadata,
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
const flipCard = useCallback(
(cardId: string) => {
if (!canFlipCard(cardId)) {
return
}
dispatch({ type: 'FLIP_CARD', cardId })
},
[canFlipCard]
)
const resetGame = useCallback(() => {
if (activePlayers.length === 0) {
console.error('[LocalMemoryPairs] Cannot reset game without active players')
return
}
const playerMetadata: { [playerId: string]: any } = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
if (playerData) {
playerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: viewerId || '',
color: playerData.color,
}
}
}
const cards = generateGameCards(state.gameType, state.difficulty)
dispatch({
type: 'START_GAME',
cards,
activePlayers,
playerMetadata,
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
const setGameType = useCallback((gameType: typeof state.gameType) => {
dispatch({ type: 'SET_CONFIG', field: 'gameType', value: gameType })
}, [])
const setDifficulty = useCallback((difficulty: typeof state.difficulty) => {
dispatch({ type: 'SET_CONFIG', field: 'difficulty', value: difficulty })
}, [])
const setTurnTimer = useCallback((turnTimer: typeof state.turnTimer) => {
dispatch({ type: 'SET_CONFIG', field: 'turnTimer', value: turnTimer })
}, [])
const resumeGame = useCallback(() => {
if (!canResumeGame) {
console.warn('[LocalMemoryPairs] Cannot resume - no paused game or config changed')
return
}
dispatch({ type: 'RESUME_GAME' })
}, [canResumeGame])
const goToSetup = useCallback(() => {
dispatch({ type: 'GO_TO_SETUP' })
}, [])
const hoverCard = useCallback(
(cardId: string | null) => {
const playerId = state.currentPlayer || activePlayers[0] || ''
if (!playerId) return
dispatch({
type: 'HOVER_CARD',
playerId,
cardId,
})
},
[state.currentPlayer, activePlayers]
)
const exitSession = useCallback(() => {
router.push('/arcade')
}, [router])
const effectiveState = { ...state, gameMode } as MemoryPairsState & {
gameMode: GameMode
}
const contextValue: MemoryPairsContextValue = {
state: effectiveState,
dispatch: () => {
// No-op - local provider uses action creators instead
console.warn('dispatch() is not available in local mode, use action creators instead')
},
isGameActive,
canFlipCard,
currentGameStatistics,
hasConfigChanged,
canResumeGame,
startGame,
resumeGame,
flipCard,
resetGame,
goToSetup,
setGameType,
setDifficulty,
setTurnTimer,
hoverCard,
exitSession,
gameMode,
activePlayers,
}
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
}

View File

@@ -1,382 +0,0 @@
'use client'
import { createContext, type ReactNode, useContext, useEffect, useReducer } from 'react'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { generateGameCards } from '../utils/cardGeneration'
import { validateMatch } from '../utils/matchValidation'
import type {
GameStatistics,
MemoryPairsAction,
MemoryPairsContextValue,
MemoryPairsState,
PlayerScore,
} from './types'
// Initial state (gameMode removed - now derived from global context)
const initialState: MemoryPairsState = {
// Core game data
cards: [],
gameCards: [],
flippedCards: [],
// Game configuration (gameMode removed)
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
// Game progression
gamePhase: 'setup',
currentPlayer: '', // Will be set to first player ID on START_GAME
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: [],
consecutiveMatches: {},
// Timing
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
// UI state
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
}
// Reducer function
function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction): MemoryPairsState {
switch (action.type) {
// SET_GAME_MODE removed - game mode now derived from global context
case 'SET_GAME_TYPE':
return {
...state,
gameType: action.gameType,
}
case 'SET_DIFFICULTY':
return {
...state,
difficulty: action.difficulty,
totalPairs: action.difficulty,
}
case 'SET_TURN_TIMER':
return {
...state,
turnTimer: action.timer,
}
case 'START_GAME': {
// Initialize scores and consecutive matches for all active players
const scores: PlayerScore = {}
const consecutiveMatches: { [playerId: string]: number } = {}
action.activePlayers.forEach((playerId) => {
scores[playerId] = 0
consecutiveMatches[playerId] = 0
})
return {
...state,
gamePhase: 'playing',
gameCards: action.cards,
cards: action.cards,
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores,
consecutiveMatches,
activePlayers: action.activePlayers,
currentPlayer: action.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
}
}
case 'FLIP_CARD': {
const cardToFlip = state.gameCards.find((card) => card.id === action.cardId)
if (
!cardToFlip ||
cardToFlip.matched ||
state.flippedCards.length >= 2 ||
state.isProcessingMove
) {
return state
}
const newFlippedCards = [...state.flippedCards, cardToFlip]
const newMoveStartTime =
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime: newMoveStartTime,
showMismatchFeedback: false,
}
}
case 'MATCH_FOUND': {
const [card1Id, card2Id] = action.cardIds
const updatedCards = state.gameCards.map((card) => {
if (card.id === card1Id || card.id === card2Id) {
return {
...card,
matched: true,
matchedBy: state.currentPlayer,
}
}
return card
})
const newMatchedPairs = state.matchedPairs + 1
const newScores = {
...state.scores,
[state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1,
}
const newConsecutiveMatches = {
...state.consecutiveMatches,
[state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1,
}
// Check if game is complete
const isGameComplete = newMatchedPairs === state.totalPairs
return {
...state,
gameCards: updatedCards,
matchedPairs: newMatchedPairs,
scores: newScores,
consecutiveMatches: newConsecutiveMatches,
flippedCards: [],
moves: state.moves + 1,
lastMatchedPair: action.cardIds,
gamePhase: isGameComplete ? 'results' : 'playing',
gameEndTime: isGameComplete ? Date.now() : null,
isProcessingMove: false,
// Note: Player keeps turn after successful match in multiplayer mode
}
}
case 'MATCH_FAILED': {
// Player switching is now handled by passing activePlayerCount
return {
...state,
flippedCards: [],
moves: state.moves + 1,
showMismatchFeedback: true,
isProcessingMove: false,
// currentPlayer will be updated by SWITCH_PLAYER action when needed
}
}
case 'SWITCH_PLAYER': {
// Cycle through all active players
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
const nextIndex = (currentIndex + 1) % state.activePlayers.length
// Reset consecutive matches for the player who failed
const newConsecutiveMatches = {
...state.consecutiveMatches,
[state.currentPlayer]: 0,
}
return {
...state,
currentPlayer: state.activePlayers[nextIndex] || state.activePlayers[0],
consecutiveMatches: newConsecutiveMatches,
}
}
case 'ADD_CELEBRATION':
return {
...state,
celebrationAnimations: [...state.celebrationAnimations, action.animation],
}
case 'REMOVE_CELEBRATION':
return {
...state,
celebrationAnimations: state.celebrationAnimations.filter(
(anim) => anim.id !== action.animationId
),
}
case 'SET_PROCESSING':
return {
...state,
isProcessingMove: action.processing,
}
case 'SET_MISMATCH_FEEDBACK':
return {
...state,
showMismatchFeedback: action.show,
}
case 'SHOW_RESULTS':
return {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
flippedCards: [],
}
case 'RESET_GAME':
return {
...initialState,
gameType: state.gameType,
difficulty: state.difficulty,
turnTimer: state.turnTimer,
totalPairs: state.difficulty,
}
case 'UPDATE_TIMER':
// This can be used for any timer-related updates
return state
default:
return state
}
}
// Create context
export const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
// Provider component
export function MemoryPairsProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(memoryPairsReducer, initialState)
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
// Get active player IDs directly from GameModeContext
const activePlayers = Array.from(activePlayerIds)
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
// Handle card matching logic when two cards are flipped
useEffect(() => {
if (state.flippedCards.length === 2 && !state.isProcessingMove) {
dispatch({ type: 'SET_PROCESSING', processing: true })
const [card1, card2] = state.flippedCards
const matchResult = validateMatch(card1, card2)
// Delay to allow card flip animation
setTimeout(() => {
if (matchResult.isValid) {
dispatch({ type: 'MATCH_FOUND', cardIds: [card1.id, card2.id] })
} else {
dispatch({ type: 'MATCH_FAILED', cardIds: [card1.id, card2.id] })
// Switch player only in multiplayer mode
if (gameMode === 'multiplayer') {
dispatch({ type: 'SWITCH_PLAYER' })
}
}
}, 1000) // Give time to see both cards
}
}, [state.flippedCards, state.isProcessingMove, gameMode])
// Auto-hide mismatch feedback
useEffect(() => {
if (state.showMismatchFeedback) {
const timeout = setTimeout(() => {
dispatch({ type: 'SET_MISMATCH_FEEDBACK', show: false })
}, 2000)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = (cardId: string): boolean => {
if (!isGameActive || state.isProcessingMove) return false
const card = state.gameCards.find((c) => c.id === cardId)
if (!card || card.matched) return false
// Can't flip if already flipped
if (state.flippedCards.some((c) => c.id === cardId)) return false
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) return false
return true
}
const currentGameStatistics: GameStatistics = {
totalMoves: state.moves,
matchedPairs: state.matchedPairs,
totalPairs: state.totalPairs,
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
averageTimePerMove:
state.moves > 0 && state.gameStartTime
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
: 0,
}
// Action creators
const startGame = () => {
const cards = generateGameCards(state.gameType, state.difficulty)
dispatch({ type: 'START_GAME', cards, activePlayers })
}
const flipCard = (cardId: string) => {
if (!canFlipCard(cardId)) return
dispatch({ type: 'FLIP_CARD', cardId })
}
const resetGame = () => {
dispatch({ type: 'RESET_GAME' })
}
// setGameMode removed - game mode is now derived from global context
const setGameType = (gameType: typeof state.gameType) => {
dispatch({ type: 'SET_GAME_TYPE', gameType })
}
const setDifficulty = (difficulty: typeof state.difficulty) => {
dispatch({ type: 'SET_DIFFICULTY', difficulty })
}
const contextValue: MemoryPairsContextValue = {
state: { ...state, gameMode }, // Add derived gameMode to state
dispatch,
isGameActive,
canFlipCard,
currentGameStatistics,
startGame,
flipCard,
resetGame,
setGameType,
setDifficulty,
exitSession: () => {}, // No-op for non-arcade mode
gameMode, // Expose derived gameMode
activePlayers, // Expose active players
}
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
}
// Hook to use the context
export function useMemoryPairs(): MemoryPairsContextValue {
const context = useContext(MemoryPairsContext)
if (!context) {
throw new Error('useMemoryPairs must be used within a MemoryPairsProvider')
}
return context
}

View File

@@ -1,151 +0,0 @@
/**
* Unit test for player ownership bug in RoomMemoryPairsProvider
*
* Bug: playerMetadata[playerId].userId is set to the LOCAL viewerId for ALL players,
* including remote players from other room members. This causes "Your turn" to show
* even when it's a remote player's turn.
*
* Fix: Use player.isLocal from GameModeContext to determine correct userId ownership.
*/
import { describe, expect, it } from 'vitest'
describe('Player Metadata userId Assignment', () => {
it('should assign local userId to local players only', () => {
const viewerId = 'local-user-id'
const players = new Map([
[
'local-player-1',
{
id: 'local-player-1',
name: 'Local Player',
emoji: '😀',
color: '#3b82f6',
isLocal: true,
},
],
[
'remote-player-1',
{
id: 'remote-player-1',
name: 'Remote Player',
emoji: '🤠',
color: '#10b981',
isLocal: false,
},
],
])
const activePlayers = ['local-player-1', 'remote-player-1']
// CURRENT BUGGY IMPLEMENTATION (from RoomMemoryPairsProvider.tsx:378-390)
const buggyPlayerMetadata: Record<string, any> = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
if (playerData) {
buggyPlayerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: viewerId, // BUG: Always uses local viewerId!
color: playerData.color,
}
}
}
// BUG MANIFESTATION: Both players have local userId
expect(buggyPlayerMetadata['local-player-1'].userId).toBe('local-user-id')
expect(buggyPlayerMetadata['remote-player-1'].userId).toBe('local-user-id') // WRONG!
// CORRECT IMPLEMENTATION
const correctPlayerMetadata: Record<string, any> = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
if (playerData) {
correctPlayerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
// FIX: Only use local viewerId for local players
// For remote players, we don't know their userId from this context,
// but we can mark them as NOT belonging to local user
userId: playerData.isLocal ? viewerId : `remote-user-${playerId}`,
color: playerData.color,
isLocal: playerData.isLocal, // Also include isLocal for clarity
}
}
}
// CORRECT BEHAVIOR: Each player has correct userId
expect(correctPlayerMetadata['local-player-1'].userId).toBe('local-user-id')
expect(correctPlayerMetadata['remote-player-1'].userId).not.toBe('local-user-id')
})
it('reproduces "Your turn" bug when checking current player', () => {
const viewerId = 'local-user-id'
const currentPlayer = 'remote-player-1' // Remote player's turn
// Buggy playerMetadata (all players have local userId)
const buggyPlayerMetadata = {
'local-player-1': {
id: 'local-player-1',
userId: 'local-user-id',
},
'remote-player-1': {
id: 'remote-player-1',
userId: 'local-user-id', // BUG!
},
}
// PlayerStatusBar logic (line 31 in PlayerStatusBar.tsx)
const buggyIsLocalPlayer = buggyPlayerMetadata[currentPlayer]?.userId === viewerId
// BUG: Shows "Your turn" even though it's remote player's turn!
expect(buggyIsLocalPlayer).toBe(true) // WRONG!
expect(buggyIsLocalPlayer ? 'Your turn' : 'Their turn').toBe('Your turn') // WRONG!
// Correct playerMetadata (each player has correct userId)
const correctPlayerMetadata = {
'local-player-1': {
id: 'local-player-1',
userId: 'local-user-id',
},
'remote-player-1': {
id: 'remote-player-1',
userId: 'remote-user-id', // CORRECT!
},
}
// PlayerStatusBar logic with correct data
const correctIsLocalPlayer = correctPlayerMetadata[currentPlayer]?.userId === viewerId
// CORRECT: Shows "Their turn" because it's remote player's turn
expect(correctIsLocalPlayer).toBe(false) // CORRECT!
expect(correctIsLocalPlayer ? 'Your turn' : 'Their turn').toBe('Their turn') // CORRECT!
})
it('reproduces hover avatar bug when filtering by current player', () => {
const viewerId = 'local-user-id'
const currentPlayer = 'remote-player-1' // Remote player's turn
// Buggy playerMetadata
const buggyPlayerMetadata = {
'remote-player-1': {
id: 'remote-player-1',
userId: 'local-user-id', // BUG!
},
}
// OLD WRONG logic from MemoryGrid.tsx (showed remote players)
const oldWrongFilter = buggyPlayerMetadata[currentPlayer]?.userId !== viewerId
expect(oldWrongFilter).toBe(false) // Would hide avatar incorrectly
// CURRENT logic in MemoryGrid.tsx (shows only current player)
// This is actually correct - show avatar for whoever's turn it is
const currentLogic = currentPlayer === 'remote-player-1'
expect(currentLogic).toBe(true) // Shows avatar for current player
// The REAL issue is in PlayerStatusBar showing "Your turn"
// when it should show "Their turn"
})
})

View File

@@ -1,20 +0,0 @@
/**
* Central export point for arcade matching game context
* Re-exports the hook from the appropriate provider
*/
// Export the hook (works with both local and room providers)
export { useMemoryPairs } from './MemoryPairsContext'
// Export the room provider (networked multiplayer)
export { RoomMemoryPairsProvider } from './RoomMemoryPairsProvider'
// Export types
export type {
GameCard,
GameMode,
GamePhase,
GameType,
MemoryPairsState,
MemoryPairsContextValue,
} from './types'

View File

@@ -1,179 +0,0 @@
// TypeScript interfaces for Memory Pairs Challenge game
export type GameMode = 'single' | 'multiplayer'
export type GameType = 'abacus-numeral' | 'complement-pairs'
export type GamePhase = 'setup' | 'playing' | 'results'
export type CardType = 'abacus' | 'number' | 'complement'
export type Difficulty = 6 | 8 | 12 | 15 // Number of pairs
export type Player = string // Player ID (UUID)
export type TargetSum = 5 | 10 | 20
export interface GameCard {
id: string
type: CardType
number: number
complement?: number // For complement pairs
targetSum?: TargetSum // For complement pairs
matched: boolean
matchedBy?: Player // For two-player mode
element?: HTMLElement | null // For animations
}
export interface PlayerScore {
[playerId: string]: number
}
export interface CelebrationAnimation {
id: string
type: 'match' | 'win' | 'confetti'
x: number
y: number
timestamp: number
}
export interface GameStatistics {
totalMoves: number
matchedPairs: number
totalPairs: number
gameTime: number
accuracy: number // Percentage of successful matches
averageTimePerMove: number
}
export interface MemoryPairsState {
// Core game data
cards: GameCard[]
gameCards: GameCard[]
flippedCards: GameCard[]
// Game configuration (gameMode removed - now derived from global context)
gameType: GameType
difficulty: Difficulty
turnTimer: number // Seconds for two-player mode
// Game progression
gamePhase: GamePhase
currentPlayer: Player
matchedPairs: number
totalPairs: number
moves: number
scores: PlayerScore
activePlayers: Player[] // Track active player IDs
playerMetadata?: { [playerId: string]: any } // Player metadata for cross-user visibility
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
// 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: Paused game state
originalConfig?: {
gameType: GameType
difficulty: Difficulty
turnTimer: number
}
pausedGamePhase?: GamePhase
pausedGameState?: {
gameCards: GameCard[]
currentPlayer: Player
matchedPairs: number
moves: number
scores: PlayerScore
activePlayers: Player[]
playerMetadata: { [playerId: string]: any }
consecutiveMatches: { [playerId: string]: number }
gameStartTime: number | null
}
// HOVER: Networked hover state
playerHovers?: { [playerId: string]: string | null }
}
export type MemoryPairsAction =
| { type: 'SET_GAME_TYPE'; gameType: GameType }
| { type: 'SET_DIFFICULTY'; difficulty: Difficulty }
| { type: 'SET_TURN_TIMER'; timer: number }
| { type: 'START_GAME'; cards: GameCard[]; activePlayers: Player[] }
| { type: 'FLIP_CARD'; cardId: string }
| { type: 'MATCH_FOUND'; cardIds: [string, string] }
| { type: 'MATCH_FAILED'; cardIds: [string, string] }
| { type: 'SWITCH_PLAYER' }
| { type: 'ADD_CELEBRATION'; animation: CelebrationAnimation }
| { type: 'REMOVE_CELEBRATION'; animationId: string }
| { type: 'SHOW_RESULTS' }
| { type: 'RESET_GAME' }
| { type: 'SET_PROCESSING'; processing: boolean }
| { type: 'SET_MISMATCH_FEEDBACK'; show: boolean }
| { type: 'UPDATE_TIMER' }
export interface MemoryPairsContextValue {
state: MemoryPairsState & { gameMode: GameMode } // gameMode added as computed property
dispatch: React.Dispatch<MemoryPairsAction>
// Computed values
isGameActive: boolean
canFlipCard: (cardId: string) => boolean
currentGameStatistics: GameStatistics
gameMode: GameMode // Derived from global context
activePlayers: Player[] // Active player IDs from arena
// PAUSE/RESUME: Computed pause/resume values
hasConfigChanged?: boolean
canResumeGame?: boolean
// Actions
startGame: () => void
flipCard: (cardId: string) => void
resetGame: () => void
setGameType: (type: GameType) => void
setDifficulty: (difficulty: Difficulty) => void
setTurnTimer?: (timer: number) => void
goToSetup?: () => void
resumeGame?: () => void
hoverCard?: (cardId: string | null) => void
exitSession: () => void
}
// Utility types for component props
export interface GameCardProps {
card: GameCard
isFlipped: boolean
isMatched: boolean
onClick: () => void
disabled?: boolean
}
export interface PlayerIndicatorProps {
player: Player
isActive: boolean
score: number
name?: string
}
export interface GameGridProps {
cards: GameCard[]
onCardClick: (cardId: string) => void
disabled?: boolean
}
// Configuration interfaces
export interface GameConfiguration {
gameMode: GameMode
gameType: GameType
difficulty: Difficulty
turnTimer: number
}
export interface MatchValidationResult {
isValid: boolean
reason?: string
type: 'abacus-numeral' | 'complement' | 'invalid'
}

View File

@@ -1,10 +0,0 @@
import { MemoryPairsGame } from './components/MemoryPairsGame'
import { LocalMemoryPairsProvider } from './context/LocalMemoryPairsProvider'
export default function MatchingPage() {
return (
<LocalMemoryPairsProvider>
<MemoryPairsGame />
</LocalMemoryPairsProvider>
)
}

View File

@@ -1,222 +0,0 @@
import type { GameCard, MatchValidationResult } from '../context/types'
// Validate abacus-numeral match (abacus card matches with number card of same value)
export function validateAbacusNumeralMatch(
card1: GameCard,
card2: GameCard
): MatchValidationResult {
// Both cards must have the same number
if (card1.number !== card2.number) {
return {
isValid: false,
reason: 'Numbers do not match',
type: 'invalid',
}
}
// Cards must be different types (one abacus, one number)
if (card1.type === card2.type) {
return {
isValid: false,
reason: 'Both cards are the same type',
type: 'invalid',
}
}
// One must be abacus, one must be number
const hasAbacus = card1.type === 'abacus' || card2.type === 'abacus'
const hasNumber = card1.type === 'number' || card2.type === 'number'
if (!hasAbacus || !hasNumber) {
return {
isValid: false,
reason: 'Must match abacus with number representation',
type: 'invalid',
}
}
// Neither should be complement type for this game mode
if (card1.type === 'complement' || card2.type === 'complement') {
return {
isValid: false,
reason: 'Complement cards not valid in abacus-numeral mode',
type: 'invalid',
}
}
return {
isValid: true,
type: 'abacus-numeral',
}
}
// Validate complement match (two numbers that add up to target sum)
export function validateComplementMatch(card1: GameCard, card2: GameCard): MatchValidationResult {
// Both cards must be complement type
if (card1.type !== 'complement' || card2.type !== 'complement') {
return {
isValid: false,
reason: 'Both cards must be complement type',
type: 'invalid',
}
}
// Both cards must have the same target sum
if (card1.targetSum !== card2.targetSum) {
return {
isValid: false,
reason: 'Cards have different target sums',
type: 'invalid',
}
}
// Check if the numbers are actually complements
if (!card1.complement || !card2.complement) {
return {
isValid: false,
reason: 'Complement information missing',
type: 'invalid',
}
}
// Verify the complement relationship
if (card1.number !== card2.complement || card2.number !== card1.complement) {
return {
isValid: false,
reason: 'Numbers are not complements of each other',
type: 'invalid',
}
}
// Verify the sum equals the target
const sum = card1.number + card2.number
if (sum !== card1.targetSum) {
return {
isValid: false,
reason: `Sum ${sum} does not equal target ${card1.targetSum}`,
type: 'invalid',
}
}
return {
isValid: true,
type: 'complement',
}
}
// Main validation function that determines which validation to use
export function validateMatch(card1: GameCard, card2: GameCard): MatchValidationResult {
// Cannot match the same card with itself
if (card1.id === card2.id) {
return {
isValid: false,
reason: 'Cannot match card with itself',
type: 'invalid',
}
}
// Cannot match already matched cards
if (card1.matched || card2.matched) {
return {
isValid: false,
reason: 'Cannot match already matched cards',
type: 'invalid',
}
}
// Determine which type of match to validate based on card types
const hasComplement = card1.type === 'complement' || card2.type === 'complement'
if (hasComplement) {
// If either card is complement type, use complement validation
return validateComplementMatch(card1, card2)
} else {
// Otherwise, use abacus-numeral validation
return validateAbacusNumeralMatch(card1, card2)
}
}
// Helper function to check if a card can be flipped
export function canFlipCard(
card: GameCard,
flippedCards: GameCard[],
isProcessingMove: boolean
): boolean {
// Cannot flip if processing a move
if (isProcessingMove) return false
// Cannot flip already matched cards
if (card.matched) return false
// Cannot flip if already flipped
if (flippedCards.some((c) => c.id === card.id)) return false
// Cannot flip if two cards are already flipped
if (flippedCards.length >= 2) return false
return true
}
// Get hint for what kind of match the player should look for
export function getMatchHint(card: GameCard): string {
switch (card.type) {
case 'abacus':
return `Find the number ${card.number}`
case 'number':
return `Find the abacus showing ${card.number}`
case 'complement':
if (card.complement !== undefined && card.targetSum !== undefined) {
return `Find ${card.complement} to make ${card.targetSum}`
}
return 'Find the matching complement'
default:
return 'Find the matching card'
}
}
// Calculate match score based on difficulty and time
export function calculateMatchScore(
difficulty: number,
timeForMatch: number,
isComplementMatch: boolean
): number {
const baseScore = isComplementMatch ? 15 : 10 // Complement matches worth more
const difficultyMultiplier = difficulty / 6 // Scale with difficulty
const timeBonus = Math.max(0, (10000 - timeForMatch) / 1000) // Bonus for speed
return Math.round(baseScore * difficultyMultiplier + timeBonus)
}
// Analyze game performance
export function analyzeGamePerformance(
totalMoves: number,
matchedPairs: number,
totalPairs: number,
gameTime: number
): {
accuracy: number
efficiency: number
averageTimePerMove: number
grade: 'A' | 'B' | 'C' | 'D' | 'F'
} {
const accuracy = totalMoves > 0 ? (matchedPairs / totalMoves) * 100 : 0
const efficiency = totalPairs > 0 ? (matchedPairs / (totalPairs * 2)) * 100 : 0 // Ideal is 100% (each pair found in 2 moves)
const averageTimePerMove = totalMoves > 0 ? gameTime / totalMoves : 0
// Calculate grade based on accuracy and efficiency
let grade: 'A' | 'B' | 'C' | 'D' | 'F' = 'F'
if (accuracy >= 90 && efficiency >= 80) grade = 'A'
else if (accuracy >= 80 && efficiency >= 70) grade = 'B'
else if (accuracy >= 70 && efficiency >= 60) grade = 'C'
else if (accuracy >= 60 && efficiency >= 50) grade = 'D'
return {
accuracy,
efficiency,
averageTimePerMove,
grade,
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,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'
@@ -13,9 +9,8 @@ import { css } from '../../../../styled-system/css'
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
// Map GameType keys to internal game names
// Note: "battle-arena" removed - now handled by game registry as "matching"
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
'battle-arena': 'matching',
'memory-quiz': 'memory-quiz',
'complement-race': 'complement-race',
'master-organizer': 'master-organizer',
}
@@ -336,21 +331,7 @@ export default function RoomPage() {
// Render legacy games based on room's gameName
switch (roomData.gameName) {
case 'matching':
return (
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
)
case 'memory-quiz':
return (
<RoomMemoryQuizProvider>
<MemoryQuizGame />
</RoomMemoryQuizProvider>
)
// TODO: Add other games (complement-race, etc.)
// TODO: Add other legacy games (complement-race, etc.) once migrated
default:
return (
<PageWithNav

View File

@@ -1,792 +0,0 @@
'use client'
import emojiData from 'emojibase-data/en/data.json'
import { useMemo, useState } from 'react'
import { css } from '../../../../../styled-system/css'
import { PLAYER_EMOJIS } from '../../../../constants/playerEmojis'
// Proper TypeScript interface for emojibase-data structure
interface EmojibaseEmoji {
label: string
hexcode: string
tags?: string[]
emoji: string
text: string
type: number
order: number
group: number
subgroup: number
version: number
emoticon?: string | string[] // Can be string, array, or undefined
}
interface EmojiPickerProps {
currentEmoji: string
onEmojiSelect: (emoji: string) => void
onClose: () => void
playerNumber: number
}
// Emoji group categories from emojibase (matching Unicode CLDR group IDs)
const EMOJI_GROUPS = {
0: { name: 'Smileys & Emotion', icon: '😀' },
1: { name: 'People & Body', icon: '👤' },
3: { name: 'Animals & Nature', icon: '🐶' },
4: { name: 'Food & Drink', icon: '🍎' },
5: { name: 'Travel & Places', icon: '🚗' },
6: { name: 'Activities', icon: '⚽' },
7: { name: 'Objects', icon: '💡' },
8: { name: 'Symbols', icon: '❤️' },
9: { name: 'Flags', icon: '🏁' },
} as const
// Create a map of emoji to their searchable data and group
const emojiMap = new Map<string, { keywords: string[]; group: number }>()
;(emojiData as EmojibaseEmoji[]).forEach((emoji) => {
if (emoji.emoji) {
// Handle emoticon field which can be string, array, or undefined
const emoticons: string[] = []
if (emoji.emoticon) {
if (Array.isArray(emoji.emoticon)) {
emoticons.push(...emoji.emoticon.map((e) => e.toLowerCase()))
} else {
emoticons.push(emoji.emoticon.toLowerCase())
}
}
emojiMap.set(emoji.emoji, {
keywords: [
emoji.label?.toLowerCase(),
...(emoji.tags || []).map((tag: string) => tag.toLowerCase()),
...emoticons,
].filter(Boolean),
group: emoji.group,
})
}
})
// Enhanced search function using emojibase-data
function getEmojiKeywords(emoji: string): string[] {
const data = emojiMap.get(emoji)
if (data) {
return data.keywords
}
// Fallback categories for emojis not in emojibase-data
if (/[\u{1F600}-\u{1F64F}]/u.test(emoji)) return ['face', 'emotion', 'person', 'expression']
if (/[\u{1F400}-\u{1F43F}]/u.test(emoji)) return ['animal', 'nature', 'cute', 'pet']
if (/[\u{1F440}-\u{1F4FF}]/u.test(emoji)) return ['object', 'symbol', 'tool']
if (/[\u{1F300}-\u{1F3FF}]/u.test(emoji)) return ['nature', 'travel', 'activity', 'place']
if (/[\u{1F680}-\u{1F6FF}]/u.test(emoji)) return ['transport', 'travel', 'vehicle']
if (/[\u{2600}-\u{26FF}]/u.test(emoji)) return ['symbol', 'misc', 'sign']
return ['misc', 'other']
}
export function EmojiPicker({
currentEmoji,
onEmojiSelect,
onClose,
playerNumber,
}: EmojiPickerProps) {
const [searchFilter, setSearchFilter] = useState('')
const [selectedCategory, setSelectedCategory] = useState<number | null>(null)
const [hoveredEmoji, setHoveredEmoji] = useState<string | null>(null)
const [hoverPosition, setHoverPosition] = useState({ x: 0, y: 0 })
// Enhanced search functionality - clear separation between default and search
const isSearching = searchFilter.trim().length > 0
const isCategoryFiltered = selectedCategory !== null && !isSearching
// Calculate which categories have emojis
const availableCategories = useMemo(() => {
const categoryCounts: Record<number, number> = {}
PLAYER_EMOJIS.forEach((emoji) => {
const data = emojiMap.get(emoji)
if (data && data.group !== undefined) {
categoryCounts[data.group] = (categoryCounts[data.group] || 0) + 1
}
})
return Object.keys(EMOJI_GROUPS)
.map(Number)
.filter((groupId) => categoryCounts[groupId] > 0)
}, [])
const displayEmojis = useMemo(() => {
// Start with all emojis
let emojis = PLAYER_EMOJIS
// Apply category filter first (unless searching)
if (isCategoryFiltered) {
emojis = emojis.filter((emoji) => {
const data = emojiMap.get(emoji)
return data && data.group === selectedCategory
})
}
// Then apply search filter
if (!isSearching) {
return emojis
}
const searchTerm = searchFilter.toLowerCase().trim()
const results = PLAYER_EMOJIS.filter((emoji) => {
const keywords = getEmojiKeywords(emoji)
return keywords.some((keyword) => keyword?.includes(searchTerm))
})
// Sort results by relevance
const sortedResults = results.sort((a, b) => {
const aKeywords = getEmojiKeywords(a)
const bKeywords = getEmojiKeywords(b)
// Exact match priority
const aExact = aKeywords.some((k) => k === searchTerm)
const bExact = bKeywords.some((k) => k === searchTerm)
if (aExact && !bExact) return -1
if (!aExact && bExact) return 1
// Word boundary matches (start of word)
const aStartsWithTerm = aKeywords.some((k) => k?.startsWith(searchTerm))
const bStartsWithTerm = bKeywords.some((k) => k?.startsWith(searchTerm))
if (aStartsWithTerm && !bStartsWithTerm) return -1
if (!aStartsWithTerm && bStartsWithTerm) return 1
// Score by number of matching keywords
const aScore = aKeywords.filter((k) => k?.includes(searchTerm)).length
const bScore = bKeywords.filter((k) => k?.includes(searchTerm)).length
return bScore - aScore
})
return sortedResults
}, [searchFilter, isSearching, selectedCategory, isCategoryFiltered])
return (
<div
className={css({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
animation: 'fadeIn 0.2s ease',
padding: '20px',
})}
>
<div
className={css({
background: 'white',
borderRadius: '20px',
padding: '24px',
width: '90vw',
height: '90vh',
maxWidth: '1200px',
maxHeight: '800px',
boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
})}
>
{/* Header */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
borderBottom: '2px solid',
borderColor: 'gray.100',
paddingBottom: '12px',
flexShrink: 0,
})}
>
<h3
className={css({
fontSize: '18px',
fontWeight: 'bold',
color: 'gray.800',
margin: 0,
})}
>
Choose Character for Player {playerNumber}
</h3>
<button
className={css({
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: 'gray.500',
_hover: { color: 'gray.700' },
padding: '4px',
})}
onClick={onClose}
>
</button>
</div>
{/* Current Selection & Search */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '16px',
flexShrink: 0,
})}
>
<div
className={css({
padding: '8px 12px',
background:
playerNumber === 1
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
: playerNumber === 2
? 'linear-gradient(135deg, #fd79a8, #e84393)'
: playerNumber === 3
? 'linear-gradient(135deg, #a78bfa, #8b5cf6)'
: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
borderRadius: '12px',
color: 'white',
display: 'flex',
alignItems: 'center',
gap: '8px',
flexShrink: 0,
})}
>
<div className={css({ fontSize: '24px' })}>{currentEmoji}</div>
<div className={css({ fontSize: '12px', fontWeight: 'bold' })}>Current</div>
</div>
<input
type="text"
placeholder="Search: face, smart, heart, animal, food..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
className={css({
flex: 1,
padding: '8px 12px',
border: '2px solid',
borderColor: 'gray.200',
borderRadius: '12px',
fontSize: '14px',
_focus: {
outline: 'none',
borderColor: 'blue.400',
boxShadow: '0 0 0 3px rgba(66, 153, 225, 0.1)',
},
})}
/>
{isSearching && (
<div
className={css({
fontSize: '12px',
color: 'gray.600',
flexShrink: 0,
padding: '4px 8px',
background: displayEmojis.length > 0 ? 'green.100' : 'red.100',
borderRadius: '8px',
border: '1px solid',
borderColor: displayEmojis.length > 0 ? 'green.300' : 'red.300',
})}
>
{displayEmojis.length > 0 ? `${displayEmojis.length} found` : '✗ No matches'}
</div>
)}
</div>
{/* Category Tabs */}
{!isSearching && (
<div
className={css({
display: 'flex',
gap: '8px',
overflowX: 'auto',
paddingBottom: '8px',
marginBottom: '12px',
flexShrink: 0,
'&::-webkit-scrollbar': {
height: '6px',
},
'&::-webkit-scrollbar-thumb': {
background: '#cbd5e1',
borderRadius: '3px',
},
})}
>
<button
onClick={() => setSelectedCategory(null)}
className={css({
padding: '8px 16px',
borderRadius: '20px',
border: selectedCategory === null ? '2px solid #3b82f6' : '2px solid #e5e7eb',
background: selectedCategory === null ? '#eff6ff' : 'white',
color: selectedCategory === null ? '#1e40af' : '#6b7280',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'all 0.2s ease',
_hover: {
background: selectedCategory === null ? '#dbeafe' : '#f9fafb',
transform: 'translateY(-1px)',
},
})}
>
All
</button>
{availableCategories.map((groupId) => {
const group = EMOJI_GROUPS[groupId as keyof typeof EMOJI_GROUPS]
return (
<button
key={groupId}
onClick={() => setSelectedCategory(Number(groupId))}
className={css({
padding: '8px 16px',
borderRadius: '20px',
border:
selectedCategory === Number(groupId)
? '2px solid #3b82f6'
: '2px solid #e5e7eb',
background: selectedCategory === Number(groupId) ? '#eff6ff' : 'white',
color: selectedCategory === Number(groupId) ? '#1e40af' : '#6b7280',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'all 0.2s ease',
_hover: {
background: selectedCategory === Number(groupId) ? '#dbeafe' : '#f9fafb',
transform: 'translateY(-1px)',
},
})}
>
{group.icon} {group.name}
</button>
)
})}
</div>
)}
{/* Search Mode Header */}
{isSearching && displayEmojis.length > 0 && (
<div
className={css({
padding: '8px 12px',
background: 'blue.50',
border: '1px solid',
borderColor: 'blue.200',
borderRadius: '8px',
marginBottom: '12px',
flexShrink: 0,
})}
>
<div
className={css({
fontSize: '14px',
fontWeight: 'bold',
color: 'blue.700',
marginBottom: '4px',
})}
>
🔍 Search Results for "{searchFilter}"
</div>
<div
className={css({
fontSize: '12px',
color: 'blue.600',
})}
>
Showing {displayEmojis.length} of {PLAYER_EMOJIS.length} emojis Clear search to see
all
</div>
</div>
)}
{/* Default Mode Header */}
{!isSearching && (
<div
className={css({
padding: '8px 12px',
background: 'gray.50',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '8px',
marginBottom: '12px',
flexShrink: 0,
})}
>
<div
className={css({
fontSize: '14px',
fontWeight: 'bold',
color: 'gray.700',
marginBottom: '4px',
})}
>
{selectedCategory !== null
? `${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].icon} ${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].name}`
: '📝 All Available Characters'}
</div>
<div
className={css({
fontSize: '12px',
color: 'gray.600',
})}
>
{displayEmojis.length} emojis{' '}
{selectedCategory !== null ? 'in category' : 'available'} Use search to find
specific emojis
</div>
</div>
)}
{/* Emoji Grid - Only show when there are emojis to display */}
{displayEmojis.length > 0 && (
<div
className={css({
flex: 1,
overflowY: 'auto',
minHeight: 0,
'&::-webkit-scrollbar': {
width: '10px',
},
'&::-webkit-scrollbar-track': {
background: '#f1f5f9',
borderRadius: '5px',
},
'&::-webkit-scrollbar-thumb': {
background: '#cbd5e1',
borderRadius: '5px',
'&:hover': {
background: '#94a3b8',
},
},
})}
>
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(16, 1fr)',
gap: '4px',
padding: '4px',
'@media (max-width: 1200px)': {
gridTemplateColumns: 'repeat(14, 1fr)',
},
'@media (max-width: 1000px)': {
gridTemplateColumns: 'repeat(12, 1fr)',
},
'@media (max-width: 800px)': {
gridTemplateColumns: 'repeat(10, 1fr)',
},
'@media (max-width: 600px)': {
gridTemplateColumns: 'repeat(8, 1fr)',
},
})}
>
{displayEmojis.map((emoji) => {
const isSelected = emoji === currentEmoji
const getSelectedBg = () => {
if (!isSelected) return 'transparent'
if (playerNumber === 1) return 'blue.100'
if (playerNumber === 2) return 'pink.100'
if (playerNumber === 3) return 'purple.100'
return 'yellow.100'
}
const getSelectedBorder = () => {
if (!isSelected) return 'transparent'
if (playerNumber === 1) return 'blue.400'
if (playerNumber === 2) return 'pink.400'
if (playerNumber === 3) return 'purple.400'
return 'yellow.400'
}
const getHoverBg = () => {
if (!isSelected) return 'gray.100'
if (playerNumber === 1) return 'blue.200'
if (playerNumber === 2) return 'pink.200'
if (playerNumber === 3) return 'purple.200'
return 'yellow.200'
}
return (
<button
key={emoji}
className={css({
aspectRatio: '1',
background: getSelectedBg(),
border: '2px solid',
borderColor: getSelectedBorder(),
borderRadius: '6px',
fontSize: '20px',
cursor: 'pointer',
transition: 'all 0.1s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
_hover: {
background: getHoverBg(),
transform: 'scale(1.15)',
zIndex: 1,
fontSize: '24px',
},
})}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
setHoveredEmoji(emoji)
setHoverPosition({
x: rect.left + rect.width / 2,
y: rect.top,
})
}}
onMouseLeave={() => setHoveredEmoji(null)}
onClick={() => {
onEmojiSelect(emoji)
}}
>
{emoji}
</button>
)
})}
</div>
</div>
)}
{/* No results message */}
{isSearching && displayEmojis.length === 0 && (
<div
className={css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
color: 'gray.500',
})}
>
<div className={css({ fontSize: '48px', marginBottom: '16px' })}>🔍</div>
<div
className={css({
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '8px',
})}
>
No emojis found for "{searchFilter}"
</div>
<div className={css({ fontSize: '14px', marginBottom: '12px' })}>
Try searching for "face", "smart", "heart", "animal", "food", etc.
</div>
<button
className={css({
background: 'blue.500',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '8px 16px',
fontSize: '12px',
cursor: 'pointer',
_hover: { background: 'blue.600' },
})}
onClick={() => setSearchFilter('')}
>
Clear search to see all {PLAYER_EMOJIS.length} emojis
</button>
</div>
)}
{/* Quick selection hint */}
<div
className={css({
marginTop: '8px',
padding: '6px 12px',
background: 'gray.50',
borderRadius: '8px',
fontSize: '11px',
color: 'gray.600',
textAlign: 'center',
flexShrink: 0,
})}
>
💡 Powered by emojibase-data Try: "face", "smart", "heart", "animal", "food" Click to
select
</div>
</div>
{/* Magnifying Glass Preview - SUPER POWERED! */}
{hoveredEmoji && (
<div
style={{
position: 'fixed',
left: `${hoverPosition.x}px`,
top: `${hoverPosition.y - 120}px`,
transform: 'translateX(-50%)',
pointerEvents: 'none',
zIndex: 10000,
animation: 'magnifyIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
>
{/* Outer glow ring */}
<div
style={{
position: 'absolute',
inset: '-20px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(59, 130, 246, 0.3) 0%, transparent 70%)',
animation: 'pulseGlow 2s ease-in-out infinite',
}}
/>
{/* Main preview card */}
<div
style={{
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
borderRadius: '24px',
padding: '20px',
boxShadow:
'0 20px 60px rgba(0, 0, 0, 0.4), 0 0 0 4px rgba(59, 130, 246, 0.6), inset 0 2px 4px rgba(255,255,255,0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '120px',
lineHeight: 1,
minWidth: '160px',
minHeight: '160px',
position: 'relative',
animation: 'emojiFloat 3s ease-in-out infinite',
}}
>
{/* Sparkle effects */}
<div
style={{
position: 'absolute',
top: '10px',
right: '10px',
fontSize: '20px',
animation: 'sparkle 1.5s ease-in-out infinite',
animationDelay: '0s',
}}
>
</div>
<div
style={{
position: 'absolute',
bottom: '15px',
left: '15px',
fontSize: '16px',
animation: 'sparkle 1.5s ease-in-out infinite',
animationDelay: '0.5s',
}}
>
</div>
<div
style={{
position: 'absolute',
top: '20px',
left: '20px',
fontSize: '12px',
animation: 'sparkle 1.5s ease-in-out infinite',
animationDelay: '1s',
}}
>
</div>
{hoveredEmoji}
</div>
{/* Arrow pointing down with glow */}
<div
style={{
position: 'absolute',
bottom: '-12px',
left: '50%',
transform: 'translateX(-50%)',
width: 0,
height: 0,
borderLeft: '14px solid transparent',
borderRight: '14px solid transparent',
borderTop: '14px solid white',
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))',
}}
/>
</div>
)}
{/* Add magnifying animations */}
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes magnifyIn {
from {
opacity: 0;
transform: translateX(-50%) scale(0.5);
}
to {
opacity: 1;
transform: translateX(-50%) scale(1);
}
}
@keyframes pulseGlow {
0%, 100% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
@keyframes emojiFloat {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-5px);
}
}
@keyframes sparkle {
0%, 100% {
opacity: 0;
transform: scale(0.5) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1) rotate(180deg);
}
}
`,
}}
/>
</div>
)
}
// Add fade in animation
const fadeInAnimation = `
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
`
// Inject animation styles
if (typeof document !== 'undefined' && !document.getElementById('emoji-picker-animations')) {
const style = document.createElement('style')
style.id = 'emoji-picker-animations'
style.textContent = fadeInAnimation
document.head.appendChild(style)
}

View File

@@ -1,563 +0,0 @@
'use client'
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import type { GameCardProps } from '../context/types'
export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false }: GameCardProps) {
const appConfig = useAbacusConfig()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// Get active players array for mapping numeric IDs to actual players
const activePlayers = Array.from(activePlayerIds)
.map((id) => playerMap.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
const cardBackStyles = css({
position: 'absolute',
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '28px',
fontWeight: 'bold',
textShadow: '1px 1px 2px rgba(0,0,0,0.3)',
cursor: disabled ? 'default' : 'pointer',
userSelect: 'none',
transition: 'all 0.2s ease',
})
const cardFrontStyles = css({
position: 'absolute',
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
borderRadius: '12px',
background: 'white',
border: '3px solid',
transform: 'rotateY(180deg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '8px',
overflow: 'hidden',
transition: 'all 0.2s ease',
})
// Dynamic styling based on card type and state
const getCardBackGradient = () => {
if (isMatched) {
// Player-specific colors for matched cards - use array index lookup
const playerIndex = card.matchedBy
? activePlayers.findIndex((p) => p.id === card.matchedBy)
: -1
if (playerIndex === 0) {
return 'linear-gradient(135deg, #74b9ff, #0984e3)' // Blue for first player
} else if (playerIndex === 1) {
return 'linear-gradient(135deg, #fd79a8, #e84393)' // Pink for second player
}
return 'linear-gradient(135deg, #48bb78, #38a169)' // Default green for single player
}
switch (card.type) {
case 'abacus':
return 'linear-gradient(135deg, #7b4397, #dc2430)'
case 'number':
return 'linear-gradient(135deg, #2E86AB, #A23B72)'
case 'complement':
return 'linear-gradient(135deg, #F18F01, #6A994E)'
default:
return 'linear-gradient(135deg, #667eea, #764ba2)'
}
}
const getCardBackIcon = () => {
if (isMatched) {
// Show player emoji for matched cards in multiplayer mode
if (card.matchedBy) {
const player = activePlayers.find((p) => p.id === card.matchedBy)
return player?.emoji || '✓'
}
return '✓' // Default checkmark for single player
}
switch (card.type) {
case 'abacus':
return '🧮'
case 'number':
return '🔢'
case 'complement':
return '🤝'
default:
return '❓'
}
}
const getBorderColor = () => {
if (isMatched) {
// Player-specific border colors for matched cards - use array index lookup
const playerIndex = card.matchedBy
? activePlayers.findIndex((p) => p.id === card.matchedBy)
: -1
if (playerIndex === 0) {
return '#74b9ff' // Blue for first player
} else if (playerIndex === 1) {
return '#fd79a8' // Pink for second player
}
return '#48bb78' // Default green for single player
}
if (isFlipped) return '#667eea'
return '#e2e8f0'
}
return (
<div
className={css({
perspective: '1000px',
width: '100%',
height: '100%',
cursor: disabled || isMatched ? 'default' : 'pointer',
transition: 'transform 0.2s ease',
_hover:
disabled || isMatched
? {}
: {
transform: 'translateY(-2px)',
},
})}
onClick={disabled || isMatched ? undefined : onClick}
>
<div
className={css({
position: 'relative',
width: '100%',
height: '100%',
textAlign: 'center',
transition: 'transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1)',
transformStyle: 'preserve-3d',
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
})}
>
{/* Card Back (hidden/face-down state) */}
<div
className={cardBackStyles}
style={{
background: getCardBackGradient(),
}}
>
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
})}
>
<div className={css({ fontSize: '32px' })}>{getCardBackIcon()}</div>
{isMatched && (
<div className={css({ fontSize: '14px', opacity: 0.9 })}>
{card.matchedBy ? 'Claimed!' : 'Matched!'}
</div>
)}
</div>
</div>
{/* Card Front (revealed/face-up state) */}
<div
className={cardFrontStyles}
style={{
borderColor: getBorderColor(),
boxShadow: isMatched
? (() => {
const playerIndex = card.matchedBy
? activePlayers.findIndex((p) => p.id === card.matchedBy)
: -1
if (playerIndex === 0) {
return '0 0 20px rgba(116, 185, 255, 0.4)' // Blue glow for first player
} else if (playerIndex === 1) {
return '0 0 20px rgba(253, 121, 168, 0.4)' // Pink glow for second player
}
return '0 0 20px rgba(72, 187, 120, 0.4)' // Default green glow
})()
: isFlipped
? '0 0 15px rgba(102, 126, 234, 0.3)'
: 'none',
}}
>
{/* Player Badge for matched cards */}
{isMatched && card.matchedBy && (
<>
{/* Explosion Ring */}
<div
className={css({
position: 'absolute',
top: '6px',
right: '6px',
width: '32px',
height: '32px',
borderRadius: '50%',
border: '3px solid',
borderColor: (() => {
const playerIndex = activePlayers.findIndex((p) => p.id === card.matchedBy)
return playerIndex === 0 ? '#74b9ff' : '#fd79a8'
})(),
animation: 'explosionRing 0.6s ease-out',
zIndex: 9,
})}
/>
{/* Main Badge */}
<div
className={css({
position: 'absolute',
top: '6px',
right: '6px',
width: '32px',
height: '32px',
borderRadius: '50%',
background: (() => {
const playerIndex = activePlayers.findIndex((p) => p.id === card.matchedBy)
return playerIndex === 0
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
: 'linear-gradient(135deg, #fd79a8, #e84393)'
})(),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
boxShadow: (() => {
const playerIndex = activePlayers.findIndex((p) => p.id === card.matchedBy)
return playerIndex === 0
? '0 0 20px rgba(116, 185, 255, 0.6), 0 0 40px rgba(116, 185, 255, 0.4)'
: '0 0 20px rgba(253, 121, 168, 0.6), 0 0 40px rgba(253, 121, 168, 0.4)'
})(),
animation: 'epicClaim 1.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
zIndex: 10,
'&::before': {
content: '""',
position: 'absolute',
top: '-2px',
left: '-2px',
right: '-2px',
bottom: '-2px',
borderRadius: '50%',
background: (() => {
const playerIndex = activePlayers.findIndex((p) => p.id === card.matchedBy)
return playerIndex === 0
? 'linear-gradient(45deg, #74b9ff, #a29bfe, #6c5ce7, #74b9ff)'
: 'linear-gradient(45deg, #fd79a8, #fdcb6e, #e17055, #fd79a8)'
})(),
animation: 'spinningHalo 2s linear infinite',
zIndex: -1,
},
})}
>
<span
className={css({
animation: 'emojiBlast 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55) 0.4s both',
filter: 'drop-shadow(0 0 8px rgba(255,255,255,0.8))',
})}
>
{activePlayers.find((p) => p.id === card.matchedBy)?.emoji || '✓'}
</span>
</div>
{/* Sparkle Effects */}
{[...Array(6)].map((_, i) => (
<div
key={i}
className={css({
position: 'absolute',
top: '22px',
right: '22px',
width: '4px',
height: '4px',
background: '#ffeaa7',
borderRadius: '50%',
animation: `sparkle${i + 1} 1.5s ease-out`,
zIndex: 8,
})}
/>
))}
</>
)}
{card.type === 'abacus' ? (
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
'& svg': {
maxWidth: '100%',
maxHeight: '100%',
},
})}
>
<AbacusReact
value={card.number}
columns="auto"
beadShape={appConfig.beadShape}
colorScheme={appConfig.colorScheme}
hideInactiveBeads={appConfig.hideInactiveBeads}
scaleFactor={0.8} // Smaller for card display
interactive={false}
showNumbers={false}
animated={false}
/>
</div>
) : card.type === 'number' ? (
<div
className={css({
fontSize: '32px',
fontWeight: 'bold',
color: 'gray.800',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
{card.number}
</div>
) : card.type === 'complement' ? (
<div
className={css({
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
})}
>
<div
className={css({
fontSize: '28px',
fontWeight: 'bold',
color: 'gray.800',
})}
>
{card.number}
</div>
<div
className={css({
fontSize: '16px',
color: 'gray.600',
display: 'flex',
alignItems: 'center',
gap: '4px',
})}
>
<span>{card.targetSum === 5 ? '✋' : '🔟'}</span>
<span>Friends</span>
</div>
{card.complement !== undefined && (
<div
className={css({
fontSize: '12px',
color: 'gray.500',
})}
>
+ {card.complement} = {card.targetSum}
</div>
)}
</div>
) : (
<div
className={css({
fontSize: '24px',
color: 'gray.500',
})}
>
?
</div>
)}
</div>
</div>
{/* Match animation overlay */}
{isMatched && (
<div
className={css({
position: 'absolute',
top: '-5px',
left: '-5px',
right: '-5px',
bottom: '-5px',
borderRadius: '16px',
background: 'linear-gradient(45deg, transparent, rgba(72, 187, 120, 0.3), transparent)',
animation: 'pulse 2s infinite',
pointerEvents: 'none',
zIndex: 1,
})}
/>
)}
</div>
)
}
// Add global animation styles
const globalCardAnimations = `
@keyframes pulse {
0%, 100% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.02);
}
}
@keyframes explosionRing {
0% {
transform: scale(0);
opacity: 1;
}
50% {
opacity: 0.8;
}
100% {
transform: scale(4);
opacity: 0;
}
}
@keyframes epicClaim {
0% {
opacity: 0;
transform: scale(0) rotate(-360deg);
}
30% {
opacity: 1;
transform: scale(1.4) rotate(-180deg);
}
60% {
transform: scale(0.8) rotate(-90deg);
}
80% {
transform: scale(1.1) rotate(-30deg);
}
100% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
}
@keyframes emojiBlast {
0% {
transform: scale(0) rotate(180deg);
opacity: 0;
}
70% {
transform: scale(1.5) rotate(-10deg);
opacity: 1;
}
85% {
transform: scale(0.9) rotate(5deg);
}
100% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
}
@keyframes spinningHalo {
0% {
transform: rotate(0deg);
opacity: 0.8;
}
50% {
opacity: 1;
}
100% {
transform: rotate(360deg);
opacity: 0.8;
}
}
@keyframes sparkle1 {
0% { transform: translate(0, 0) scale(0); opacity: 1; }
50% { opacity: 1; }
100% { transform: translate(-20px, -15px) scale(1); opacity: 0; }
}
@keyframes sparkle2 {
0% { transform: translate(0, 0) scale(0); opacity: 1; }
50% { opacity: 1; }
100% { transform: translate(15px, -20px) scale(1); opacity: 0; }
}
@keyframes sparkle3 {
0% { transform: translate(0, 0) scale(0); opacity: 1; }
50% { opacity: 1; }
100% { transform: translate(-25px, 10px) scale(1); opacity: 0; }
}
@keyframes sparkle4 {
0% { transform: translate(0, 0) scale(0); opacity: 1; }
50% { opacity: 1; }
100% { transform: translate(20px, 15px) scale(1); opacity: 0; }
}
@keyframes sparkle5 {
0% { transform: translate(0, 0) scale(0); opacity: 1; }
50% { opacity: 1; }
100% { transform: translate(-10px, -25px) scale(1); opacity: 0; }
}
@keyframes sparkle6 {
0% { transform: translate(0, 0) scale(0); opacity: 1; }
50% { opacity: 1; }
100% { transform: translate(25px, -5px) scale(1); opacity: 0; }
}
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
opacity: 1;
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes cardFlip {
0% { transform: rotateY(0deg); }
100% { transform: rotateY(180deg); }
}
@keyframes matchSuccess {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
@keyframes invalidMove {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-3px); }
75% { transform: translateX(3px); }
}
`
// Inject global styles
if (typeof document !== 'undefined' && !document.getElementById('memory-card-animations')) {
const style = document.createElement('style')
style.id = 'memory-card-animations'
style.textContent = globalCardAnimations
document.head.appendChild(style)
}

View File

@@ -1,85 +0,0 @@
'use client'
import { useMemo } from 'react'
import { MemoryGrid } from '@/components/matching/MemoryGrid'
import { css } from '../../../../../styled-system/css'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { getGridConfiguration } from '../utils/cardGeneration'
import { GameCard } from './GameCard'
export function GamePhase() {
const { state, flipCard } = useMemoryPairs()
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
return (
<div
className={css({
width: '100%',
height: '100%',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
})}
>
{/* Game header removed - game type and player info now shown in nav bar */}
{/* Memory Grid - The main game area */}
<div
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden',
})}
>
<MemoryGrid
state={state}
gridConfig={gridConfig}
flipCard={flipCard}
enableMultiplayerPresence={false}
renderCard={({ card, isFlipped, isMatched, onClick, disabled }) => (
<GameCard
card={card}
isFlipped={isFlipped}
isMatched={isMatched}
onClick={onClick}
disabled={disabled}
/>
)}
/>
</div>
{/* Quick Tip - Only show when game is starting and on larger screens */}
{state.moves === 0 && (
<div
className={css({
textAlign: 'center',
marginTop: '12px',
padding: '8px 16px',
background: 'rgba(248, 250, 252, 0.7)',
borderRadius: '8px',
border: '1px solid rgba(226, 232, 240, 0.6)',
display: { base: 'none', lg: 'block' },
flexShrink: 0,
})}
>
<p
className={css({
fontSize: '13px',
color: 'gray.600',
margin: 0,
fontWeight: 'medium',
})}
>
💡{' '}
{state.gameType === 'abacus-numeral'
? 'Match abacus beads with numbers'
: 'Find pairs that add to 5 or 10'}
</p>
</div>
)}
</div>
)
}

View File

@@ -1,77 +0,0 @@
'use client'
import { useEffect, useRef } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../../styled-system/css'
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
import { useFullscreen } from '../../../../contexts/FullscreenContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { GamePhase } from './GamePhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
export function MemoryPairsGame() {
const { state } = useMemoryPairs()
const { setFullscreenElement } = useFullscreen()
const gameRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// Register this component's main div as the fullscreen element
if (gameRef.current) {
console.log('🎯 MemoryPairsGame: Registering fullscreen element:', gameRef.current)
setFullscreenElement(gameRef.current)
}
}, [setFullscreenElement])
// Determine nav title and emoji based on game type
const navTitle = state.gameType === 'abacus-numeral' ? 'Abacus Match' : 'Complement Pairs'
const navEmoji = state.gameType === 'abacus-numeral' ? '🧮' : '🤝'
return (
<PageWithNav
navTitle={navTitle}
navEmoji={navEmoji}
gameName="matching"
emphasizePlayerSelection={state.gamePhase === 'setup'}
currentPlayerId={state.currentPlayer}
playerScores={state.scores}
playerStreaks={state.consecutiveMatches}
>
<StandardGameLayout>
<div
ref={gameRef}
className={css({
flex: 1,
padding: { base: '12px', sm: '16px', md: '20px' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative',
overflow: 'auto',
})}
>
{/* Note: Fullscreen restore prompt removed - client-side navigation preserves fullscreen */}
<main
className={css({
width: '100%',
maxWidth: '1200px',
background: 'rgba(255,255,255,0.95)',
borderRadius: { base: '12px', md: '20px' },
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
})}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <GamePhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</main>
</div>
</StandardGameLayout>
</PageWithNav>
)
}

View File

@@ -1,533 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react'
import type React from 'react'
import { useEffect } from 'react'
import { css } from '../../../../../styled-system/css'
import { gamePlurals } from '../../../../utils/pluralization'
// Inject the celebration animations for Storybook
const celebrationAnimations = `
@keyframes gentle-pulse {
0%, 100% {
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.3), 0 12px 32px rgba(0,0,0,0.1);
}
50% {
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.5), 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes gentle-bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes gentle-sway {
0%, 100% { transform: rotate(-2deg) scale(1); }
50% { transform: rotate(2deg) scale(1.05); }
}
@keyframes breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-6px); }
}
@keyframes turn-entrance {
0% {
transform: scale(0.8) rotate(-10deg);
opacity: 0.6;
}
50% {
transform: scale(1.1) rotate(5deg);
opacity: 1;
}
100% {
transform: scale(1.08) rotate(0deg);
opacity: 1;
}
}
@keyframes streak-pulse {
0%, 100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
}
@keyframes great-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
}
50% {
transform: scale(1.12) translateY(-6px);
box-shadow: 0 0 0 2px white, 0 0 0 8px #22c55e60, 0 15px 35px rgba(34,197,94,0.3);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes epic-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
}
25% {
transform: scale(1.15) translateY(-8px) rotate(2deg);
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
}
75% {
transform: scale(1.15) translateY(-8px) rotate(-2deg);
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes legendary-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
}
20% {
transform: scale(1.2) translateY(-12px) rotate(5deg);
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
}
40% {
transform: scale(1.18) translateY(-10px) rotate(-3deg);
box-shadow: 0 0 0 3px gold, 0 0 0 10px #a855f7, 0 20px 45px rgba(168,85,247,0.4);
}
60% {
transform: scale(1.22) translateY(-14px) rotate(3deg);
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
}
80% {
transform: scale(1.15) translateY(-8px) rotate(-1deg);
box-shadow: 0 0 0 3px gold, 0 0 0 8px #a855f7, 0 18px 40px rgba(168,85,247,0.3);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
}
}
`
// Component to inject animations
const AnimationProvider = ({ children }: { children: React.ReactNode }) => {
useEffect(() => {
if (typeof document !== 'undefined' && !document.getElementById('celebration-animations')) {
const style = document.createElement('style')
style.id = 'celebration-animations'
style.textContent = celebrationAnimations
document.head.appendChild(style)
}
}, [])
return <>{children}</>
}
const meta: Meta = {
title: 'Games/Matching/PlayerStatusBar',
parameters: {
layout: 'centered',
docs: {
description: {
component: `
The PlayerStatusBar component displays the current state of players in the matching game.
It shows different layouts for single player vs multiplayer modes and includes escalating
celebration effects for consecutive matching pairs.
## Features
- Single player mode with epic styling
- Multiplayer mode with competitive grid layout
- Escalating celebration animations based on consecutive matches:
- 2+ matches: Great celebration (green)
- 3+ matches: Epic celebration (orange)
- 5+ matches: Legendary celebration (purple with gold accents)
- Real-time turn indicators
- Score tracking and progress display
- Responsive design for mobile and desktop
## Animation Preview
The animations demonstrate different celebration levels that activate when players get consecutive matches.
`,
},
},
},
decorators: [
(Story) => (
<AnimationProvider>
<div
className={css({
width: '800px',
maxWidth: '90vw',
padding: '20px',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
minHeight: '400px',
})}
>
<Story />
</div>
</AnimationProvider>
),
],
}
export default meta
type Story = StoryObj<typeof meta>
// Create a mock player card component that showcases the animations
const MockPlayerCard = ({
emoji,
name,
score,
consecutiveMatches,
isCurrentPlayer = true,
celebrationLevel,
}: {
emoji: string
name: string
score: number
consecutiveMatches: number
isCurrentPlayer?: boolean
celebrationLevel: 'normal' | 'great' | 'epic' | 'legendary'
}) => {
const playerColor =
celebrationLevel === 'legendary'
? '#a855f7'
: celebrationLevel === 'epic'
? '#f97316'
: celebrationLevel === 'great'
? '#22c55e'
: '#3b82f6'
return (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '3', md: '4' },
p: isCurrentPlayer ? { base: '4', md: '6' } : { base: '2', md: '3' },
rounded: isCurrentPlayer ? '2xl' : 'lg',
background: isCurrentPlayer
? `linear-gradient(135deg, ${playerColor}15, ${playerColor}25, ${playerColor}15)`
: 'white',
border: isCurrentPlayer ? '4px solid' : '2px solid',
borderColor: isCurrentPlayer ? playerColor : 'gray.200',
boxShadow: isCurrentPlayer
? `0 0 0 2px white, 0 0 0 6px ${playerColor}40, 0 12px 32px rgba(0,0,0,0.2)`
: '0 2px 4px rgba(0,0,0,0.1)',
transition: 'all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
position: 'relative',
transform: isCurrentPlayer ? 'scale(1.08) translateY(-4px)' : 'scale(1)',
zIndex: isCurrentPlayer ? 10 : 1,
animation: isCurrentPlayer
? celebrationLevel === 'legendary'
? 'legendary-celebration 0.8s ease-out, turn-entrance 0.6s ease-out'
: celebrationLevel === 'epic'
? 'epic-celebration 0.7s ease-out, turn-entrance 0.6s ease-out'
: celebrationLevel === 'great'
? 'great-celebration 0.6s ease-out, turn-entrance 0.6s ease-out'
: 'turn-entrance 0.6s ease-out'
: 'none',
})}
>
{/* Player emoji */}
<div
className={css({
fontSize: isCurrentPlayer ? { base: '3xl', md: '5xl' } : { base: 'lg', md: 'xl' },
flexShrink: 0,
animation: isCurrentPlayer
? 'float 3s ease-in-out infinite'
: 'breathe 5s ease-in-out infinite',
transform: isCurrentPlayer ? 'scale(1.2)' : 'scale(1)',
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
textShadow: isCurrentPlayer ? '0 0 20px currentColor' : 'none',
})}
>
{emoji}
</div>
{/* Player info */}
<div
className={css({
flex: 1,
minWidth: 0,
})}
>
<div
className={css({
fontSize: isCurrentPlayer ? { base: 'md', md: 'lg' } : { base: 'xs', md: 'sm' },
fontWeight: 'black',
color: isCurrentPlayer ? 'gray.900' : 'gray.700',
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none',
})}
>
{name}
</div>
<div
className={css({
fontSize: isCurrentPlayer ? { base: 'sm', md: 'md' } : { base: '2xs', md: 'xs' },
color: isCurrentPlayer ? playerColor : 'gray.500',
fontWeight: isCurrentPlayer ? 'black' : 'semibold',
})}
>
{gamePlurals.pair(score)}
{isCurrentPlayer && (
<span
className={css({
color: 'red.600',
fontWeight: 'black',
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
textShadow: '0 0 15px currentColor',
})}
>
{' • Your turn'}
</span>
)}
{consecutiveMatches > 1 && (
<div
className={css({
fontSize: { base: '2xs', md: 'xs' },
color:
celebrationLevel === 'legendary'
? 'purple.600'
: celebrationLevel === 'epic'
? 'orange.600'
: celebrationLevel === 'great'
? 'green.600'
: 'gray.500',
fontWeight: 'black',
animation: isCurrentPlayer ? 'streak-pulse 1s ease-in-out infinite' : 'none',
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none',
})}
>
🔥 {consecutiveMatches} streak!
</div>
)}
</div>
</div>
{/* Epic score display */}
{isCurrentPlayer && (
<div
className={css({
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
color: 'white',
px: { base: '3', md: '4' },
py: { base: '2', md: '3' },
rounded: 'xl',
fontSize: { base: 'lg', md: 'xl' },
fontWeight: 'black',
boxShadow: '0 4px 15px rgba(238, 90, 36, 0.4)',
animation: 'gentle-bounce 1.5s ease-in-out infinite',
textShadow: '0 0 10px rgba(255,255,255,0.8)',
})}
>
{score}
</div>
)}
</div>
)
}
// Normal celebration level
export const NormalPlayer: Story = {
render: () => (
<MockPlayerCard
emoji="🚀"
name="Solo Champion"
score={3}
consecutiveMatches={0}
celebrationLevel="normal"
/>
),
}
// Great celebration level
export const GreatStreak: Story = {
render: () => (
<MockPlayerCard
emoji="🎯"
name="Streak Master"
score={5}
consecutiveMatches={2}
celebrationLevel="great"
/>
),
}
// Epic celebration level
export const EpicStreak: Story = {
render: () => (
<MockPlayerCard
emoji="🔥"
name="Epic Matcher"
score={7}
consecutiveMatches={4}
celebrationLevel="epic"
/>
),
}
// Legendary celebration level
export const LegendaryStreak: Story = {
render: () => (
<MockPlayerCard
emoji="👑"
name="Legend"
score={8}
consecutiveMatches={6}
celebrationLevel="legendary"
/>
),
}
// All levels showcase
export const AllCelebrationLevels: Story = {
render: () => (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '20px' })}>
<h3
className={css({
textAlign: 'center',
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '20px',
})}
>
Consecutive Match Celebration Levels
</h3>
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))',
gap: '20px',
})}
>
{/* Normal */}
<div>
<h4
className={css({
textAlign: 'center',
marginBottom: '10px',
fontSize: '16px',
fontWeight: 'bold',
})}
>
Normal (0-1 matches)
</h4>
<MockPlayerCard
emoji="🚀"
name="Solo Champion"
score={3}
consecutiveMatches={0}
celebrationLevel="normal"
/>
</div>
{/* Great */}
<div>
<h4
className={css({
textAlign: 'center',
marginBottom: '10px',
color: 'green.600',
fontSize: '16px',
fontWeight: 'bold',
})}
>
Great (2+ matches)
</h4>
<MockPlayerCard
emoji="🎯"
name="Streak Master"
score={5}
consecutiveMatches={2}
celebrationLevel="great"
/>
</div>
{/* Epic */}
<div>
<h4
className={css({
textAlign: 'center',
marginBottom: '10px',
color: 'orange.600',
fontSize: '16px',
fontWeight: 'bold',
})}
>
Epic (3+ matches)
</h4>
<MockPlayerCard
emoji="🔥"
name="Epic Matcher"
score={7}
consecutiveMatches={4}
celebrationLevel="epic"
/>
</div>
{/* Legendary */}
<div>
<h4
className={css({
textAlign: 'center',
marginBottom: '10px',
color: 'purple.600',
fontSize: '16px',
fontWeight: 'bold',
})}
>
Legendary (5+ matches)
</h4>
<MockPlayerCard
emoji="👑"
name="Legend"
score={8}
consecutiveMatches={6}
celebrationLevel="legendary"
/>
</div>
</div>
<div
className={css({
textAlign: 'center',
marginTop: '20px',
padding: '16px',
background: 'rgba(255,255,255,0.8)',
borderRadius: '12px',
border: '1px solid rgba(0,0,0,0.1)',
})}
>
<p className={css({ fontSize: '14px', color: 'gray.700', margin: 0 })}>
These animations trigger when a player gets consecutive matching pairs in the memory
matching game. The celebrations get more intense as the streak grows, providing visual
feedback and excitement!
</p>
</div>
</div>
),
parameters: {
layout: 'fullscreen',
},
}

View File

@@ -1,500 +0,0 @@
'use client'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { gamePlurals } from '../../../../utils/pluralization'
import { useMemoryPairs } from '../context/MemoryPairsContext'
interface PlayerStatusBarProps {
className?: string
}
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
const { state } = useMemoryPairs()
// Get active players array
const activePlayersData = Array.from(activePlayerIds)
.map((id) => playerMap.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
// Map active players to display data with scores
// State uses UUID player IDs, so we map by player.id
const activePlayers = activePlayersData.map((player) => ({
...player,
displayName: player.name,
displayEmoji: player.emoji,
score: state.scores[player.id] || 0,
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0,
}))
// Get celebration level based on consecutive matches
const getCelebrationLevel = (consecutiveMatches: number) => {
if (consecutiveMatches >= 5) return 'legendary'
if (consecutiveMatches >= 3) return 'epic'
if (consecutiveMatches >= 2) return 'great'
return 'normal'
}
if (activePlayers.length <= 1) {
// Simple single player indicator
return (
<div
className={`${css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: 'white',
rounded: 'lg',
p: { base: '2', md: '3' },
border: '2px solid',
borderColor: 'blue.200',
mb: { base: '2', md: '3' },
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
})} ${className || ''}`}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '2', md: '3' },
})}
>
<div
className={css({
fontSize: { base: 'xl', md: '2xl' },
})}
>
{activePlayers[0]?.displayEmoji || '🚀'}
</div>
<div
className={css({
fontSize: { base: 'sm', md: 'md' },
fontWeight: 'bold',
color: 'gray.700',
})}
>
{activePlayers[0]?.displayName || 'Player 1'}
</div>
<div
className={css({
fontSize: { base: 'xs', md: 'sm' },
color: 'blue.600',
fontWeight: 'medium',
})}
>
{gamePlurals.pair(state.matchedPairs)} of {state.totalPairs} {' '}
{gamePlurals.move(state.moves)}
</div>
</div>
</div>
)
}
// For multiplayer, show competitive status bar
return (
<div
className={`${css({
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
rounded: 'xl',
p: { base: '2', md: '3' },
border: '2px solid',
borderColor: 'gray.200',
mb: { base: '3', md: '4' },
})} ${className || ''}`}
>
<div
className={css({
display: 'grid',
gridTemplateColumns:
activePlayers.length <= 2
? 'repeat(2, 1fr)'
: activePlayers.length === 3
? 'repeat(3, 1fr)'
: 'repeat(2, 1fr) repeat(2, 1fr)',
gap: { base: '2', md: '3' },
alignItems: 'center',
})}
>
{activePlayers.map((player, _index) => {
const isCurrentPlayer = player.id === state.currentPlayer
const isLeading =
player.score === Math.max(...activePlayers.map((p) => p.score)) && player.score > 0
const celebrationLevel = getCelebrationLevel(player.consecutiveMatches)
return (
<div
key={player.id}
className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '2', md: '3' },
p: isCurrentPlayer ? { base: '3', md: '4' } : { base: '2', md: '2' },
rounded: isCurrentPlayer ? '2xl' : 'lg',
background: isCurrentPlayer
? `linear-gradient(135deg, ${player.color || '#3b82f6'}15, ${player.color || '#3b82f6'}25, ${player.color || '#3b82f6'}15)`
: 'white',
border: isCurrentPlayer ? '4px solid' : '2px solid',
borderColor: isCurrentPlayer ? player.color || '#3b82f6' : 'gray.200',
boxShadow: isCurrentPlayer
? '0 0 0 2px white, 0 0 0 6px ' +
(player.color || '#3b82f6') +
'40, 0 12px 32px rgba(0,0,0,0.2)'
: '0 2px 4px rgba(0,0,0,0.1)',
transition: 'all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
position: 'relative',
transform: isCurrentPlayer ? 'scale(1.08) translateY(-4px)' : 'scale(1)',
zIndex: isCurrentPlayer ? 10 : 1,
animation: isCurrentPlayer
? celebrationLevel === 'legendary'
? 'legendary-celebration 0.8s ease-out, turn-entrance 0.6s ease-out'
: celebrationLevel === 'epic'
? 'epic-celebration 0.7s ease-out, turn-entrance 0.6s ease-out'
: celebrationLevel === 'great'
? 'great-celebration 0.6s ease-out, turn-entrance 0.6s ease-out'
: 'turn-entrance 0.6s ease-out'
: 'none',
})}
>
{/* Leading crown with sparkle */}
{isLeading && (
<div
className={css({
position: 'absolute',
top: isCurrentPlayer ? '-3' : '-1',
right: isCurrentPlayer ? '-3' : '-1',
background: 'linear-gradient(135deg, #ffd700, #ffaa00)',
rounded: 'full',
w: isCurrentPlayer ? '10' : '6',
h: isCurrentPlayer ? '10' : '6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: isCurrentPlayer ? 'lg' : 'xs',
zIndex: 10,
animation: 'none',
boxShadow: '0 0 20px rgba(255, 215, 0, 0.6)',
})}
>
👑
</div>
)}
{/* Subtle turn indicator */}
{isCurrentPlayer && (
<div
className={css({
position: 'absolute',
top: '-2',
left: '-2',
background: player.color || '#3b82f6',
rounded: 'full',
w: '4',
h: '4',
animation: 'gentle-sway 2s ease-in-out infinite',
zIndex: 5,
})}
/>
)}
{/* Living, breathing player emoji */}
<div
className={css({
fontSize: isCurrentPlayer ? { base: '2xl', md: '3xl' } : { base: 'lg', md: 'xl' },
flexShrink: 0,
animation: isCurrentPlayer
? 'float 3s ease-in-out infinite'
: 'breathe 5s ease-in-out infinite',
transform: isCurrentPlayer ? 'scale(1.2)' : 'scale(1)',
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
textShadow: isCurrentPlayer ? '0 0 20px currentColor' : 'none',
cursor: 'pointer',
'&:hover': {
transform: isCurrentPlayer ? 'scale(1.3)' : 'scale(1.1)',
animation: 'gentle-sway 1s ease-in-out infinite',
},
})}
>
{player.displayEmoji}
</div>
{/* Enhanced player info */}
<div
className={css({
flex: 1,
minWidth: 0,
})}
>
<div
className={css({
fontSize: isCurrentPlayer ? { base: 'md', md: 'lg' } : { base: 'xs', md: 'sm' },
fontWeight: 'black',
color: isCurrentPlayer ? 'gray.900' : 'gray.700',
animation: 'none',
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none',
})}
>
{player.displayName}
</div>
<div
className={css({
fontSize: isCurrentPlayer
? { base: 'sm', md: 'md' }
: { base: '2xs', md: 'xs' },
color: isCurrentPlayer ? player.color || '#3b82f6' : 'gray.500',
fontWeight: isCurrentPlayer ? 'black' : 'semibold',
animation: 'none',
})}
>
{gamePlurals.pair(player.score)}
{isCurrentPlayer && (
<span
className={css({
color: 'red.600',
fontWeight: 'black',
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
animation: 'none',
textShadow: '0 0 15px currentColor',
})}
>
{' • Your turn'}
</span>
)}
{player.consecutiveMatches > 1 && (
<div
className={css({
fontSize: { base: '2xs', md: 'xs' },
color:
celebrationLevel === 'legendary'
? 'purple.600'
: celebrationLevel === 'epic'
? 'orange.600'
: celebrationLevel === 'great'
? 'green.600'
: 'gray.500',
fontWeight: 'black',
animation: isCurrentPlayer
? 'streak-pulse 1s ease-in-out infinite'
: 'none',
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none',
})}
>
🔥 {player.consecutiveMatches} streak!
</div>
)}
</div>
</div>
{/* Simple score display for current player */}
{isCurrentPlayer && (
<div
className={css({
background: 'blue.500',
color: 'white',
px: { base: '2', md: '3' },
py: { base: '1', md: '2' },
rounded: 'md',
fontSize: { base: 'sm', md: 'md' },
fontWeight: 'bold',
})}
>
{player.score}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
// Epic animations for extreme emphasis
const epicAnimations = `
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.1);
}
}
@keyframes gentle-pulse {
0%, 100% {
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.3), 0 12px 32px rgba(0,0,0,0.1);
}
50% {
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.5), 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes gentle-bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes gentle-sway {
0%, 100% { transform: rotate(-2deg) scale(1); }
50% { transform: rotate(2deg) scale(1.05); }
}
@keyframes breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-6px); }
}
@keyframes turn-entrance {
0% {
transform: scale(0.8) rotate(-10deg);
opacity: 0.6;
}
50% {
transform: scale(1.1) rotate(5deg);
opacity: 1;
}
100% {
transform: scale(1.08) rotate(0deg);
opacity: 1;
}
}
@keyframes turn-exit {
0% {
transform: scale(1.08);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0.8;
}
}
@keyframes spotlight {
0%, 100% {
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.3) 50%, transparent 70%);
transform: translateX(-100%);
}
50% {
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.6) 50%, transparent 70%);
transform: translateX(100%);
}
}
@keyframes neon-flicker {
0%, 100% {
text-shadow: 0 0 5px currentColor, 0 0 10px currentColor, 0 0 15px currentColor;
opacity: 1;
}
50% {
text-shadow: 0 0 2px currentColor, 0 0 5px currentColor, 0 0 8px currentColor;
opacity: 0.8;
}
}
@keyframes crown-sparkle {
0%, 100% {
transform: rotate(0deg) scale(1);
filter: brightness(1);
}
25% {
transform: rotate(-5deg) scale(1.1);
filter: brightness(1.5);
}
75% {
transform: rotate(5deg) scale(1.1);
filter: brightness(1.5);
}
}
@keyframes streak-pulse {
0%, 100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
}
@keyframes great-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
}
50% {
transform: scale(1.12) translateY(-6px);
box-shadow: 0 0 0 2px white, 0 0 0 8px #22c55e60, 0 15px 35px rgba(34,197,94,0.3);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes epic-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
}
25% {
transform: scale(1.15) translateY(-8px) rotate(2deg);
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
}
75% {
transform: scale(1.15) translateY(-8px) rotate(-2deg);
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes legendary-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
}
20% {
transform: scale(1.2) translateY(-12px) rotate(5deg);
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
}
40% {
transform: scale(1.18) translateY(-10px) rotate(-3deg);
box-shadow: 0 0 0 3px gold, 0 0 0 10px #a855f7, 0 20px 45px rgba(168,85,247,0.4);
}
60% {
transform: scale(1.22) translateY(-14px) rotate(3deg);
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
}
80% {
transform: scale(1.15) translateY(-8px) rotate(-1deg);
box-shadow: 0 0 0 3px gold, 0 0 0 8px #a855f7, 0 18px 40px rgba(168,85,247,0.3);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
}
}
`
// Inject animation styles
if (typeof document !== 'undefined' && !document.getElementById('player-status-animations')) {
const style = document.createElement('style')
style.id = 'player-status-animations'
style.textContent = epicAnimations
document.head.appendChild(style)
}

View File

@@ -1,376 +0,0 @@
'use client'
import { useRouter } from 'next/navigation'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
export function ResultsPhase() {
const router = useRouter()
const { state, resetGame, activePlayers, gameMode } = useMemoryPairs()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// Get active player data array
const activePlayerData = Array.from(activePlayerIds)
.map((id) => playerMap.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
.map((player) => ({
...player,
displayName: player.name,
displayEmoji: player.emoji,
}))
const gameTime =
state.gameEndTime && state.gameStartTime ? state.gameEndTime - state.gameStartTime : 0
const analysis = getPerformanceAnalysis(state)
const multiplayerResult =
gameMode === 'multiplayer' ? getMultiplayerWinner(state, activePlayers) : null
return (
<div
className={css({
textAlign: 'center',
padding: '40px 20px',
})}
>
{/* Celebration Header */}
<div
className={css({
marginBottom: '40px',
})}
>
<h2
className={css({
fontSize: '48px',
marginBottom: '16px',
color: 'green.600',
fontWeight: 'bold',
})}
>
🎉 Game Complete! 🎉
</h2>
{gameMode === 'single' ? (
<p
className={css({
fontSize: '24px',
color: 'gray.700',
marginBottom: '20px',
})}
>
Congratulations on completing the memory challenge!
</p>
) : (
multiplayerResult && (
<div className={css({ marginBottom: '20px' })}>
{multiplayerResult.isTie ? (
<p
className={css({
fontSize: '24px',
color: 'purple.600',
fontWeight: 'bold',
})}
>
🤝 It's a tie! All champions are memory masters!
</p>
) : multiplayerResult.winners.length === 1 ? (
<p
className={css({
fontSize: '24px',
color: 'blue.600',
fontWeight: 'bold',
})}
>
🏆{' '}
{activePlayerData.find((p) => p.id === multiplayerResult.winners[0])
?.displayName || `Player ${multiplayerResult.winners[0]}`}{' '}
Wins!
</p>
) : (
<p
className={css({
fontSize: '24px',
color: 'purple.600',
fontWeight: 'bold',
})}
>
🏆 {multiplayerResult.winners.length} Champions tied for victory!
</p>
)}
</div>
)
)}
{/* Star Rating */}
<div
className={css({
fontSize: '32px',
marginBottom: '20px',
})}
>
{''.repeat(analysis.starRating)}
{''.repeat(5 - analysis.starRating)}
</div>
<div
className={css({
fontSize: '24px',
fontWeight: 'bold',
color: 'orange.600',
})}
>
Grade: {analysis.grade}
</div>
</div>
{/* Game Statistics */}
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
marginBottom: '40px',
maxWidth: '800px',
margin: '0 auto 40px auto',
})}
>
<div
className={css({
background: 'linear-gradient(135deg, #667eea, #764ba2)',
color: 'white',
padding: '24px',
borderRadius: '16px',
textAlign: 'center',
})}
>
<div className={css({ fontSize: '32px', fontWeight: 'bold' })}>{state.matchedPairs}</div>
<div className={css({ fontSize: '16px', opacity: 0.9 })}>Pairs Matched</div>
</div>
<div
className={css({
background: 'linear-gradient(135deg, #a78bfa, #8b5cf6)',
color: 'white',
padding: '24px',
borderRadius: '16px',
textAlign: 'center',
})}
>
<div className={css({ fontSize: '32px', fontWeight: 'bold' })}>{state.moves}</div>
<div className={css({ fontSize: '16px', opacity: 0.9 })}>Total Moves</div>
</div>
<div
className={css({
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
color: 'white',
padding: '24px',
borderRadius: '16px',
textAlign: 'center',
})}
>
<div className={css({ fontSize: '32px', fontWeight: 'bold' })}>
{formatGameTime(gameTime)}
</div>
<div className={css({ fontSize: '16px', opacity: 0.9 })}>Game Time</div>
</div>
<div
className={css({
background: 'linear-gradient(135deg, #55a3ff, #003d82)',
color: 'white',
padding: '24px',
borderRadius: '16px',
textAlign: 'center',
})}
>
<div className={css({ fontSize: '32px', fontWeight: 'bold' })}>
{Math.round(analysis.statistics.accuracy)}%
</div>
<div className={css({ fontSize: '16px', opacity: 0.9 })}>Accuracy</div>
</div>
</div>
{/* Multiplayer Scores */}
{gameMode === 'multiplayer' && multiplayerResult && (
<div
className={css({
display: 'flex',
justifyContent: 'center',
gap: '20px',
marginBottom: '40px',
flexWrap: 'wrap',
})}
>
{activePlayerData.map((player) => {
const score = multiplayerResult.scores[player.id] || 0
const isWinner = multiplayerResult.winners.includes(player.id)
return (
<div
key={player.id}
className={css({
background: isWinner
? 'linear-gradient(135deg, #ffd700, #ff8c00)'
: 'linear-gradient(135deg, #c0c0c0, #808080)',
color: 'white',
padding: '20px',
borderRadius: '16px',
textAlign: 'center',
minWidth: '150px',
})}
>
<div className={css({ fontSize: '48px', marginBottom: '8px' })}>
{player.displayEmoji}
</div>
<div
className={css({
fontSize: '14px',
marginBottom: '4px',
opacity: 0.9,
})}
>
{player.displayName}
</div>
<div className={css({ fontSize: '36px', fontWeight: 'bold' })}>{score}</div>
{isWinner && <div className={css({ fontSize: '24px' })}>👑</div>}
</div>
)
})}
</div>
)}
{/* Performance Analysis */}
<div
className={css({
background: 'rgba(248, 250, 252, 0.8)',
padding: '30px',
borderRadius: '16px',
marginBottom: '40px',
border: '1px solid rgba(226, 232, 240, 0.8)',
maxWidth: '600px',
margin: '0 auto 40px auto',
})}
>
<h3
className={css({
fontSize: '24px',
marginBottom: '20px',
color: 'gray.800',
})}
>
Performance Analysis
</h3>
{analysis.strengths.length > 0 && (
<div className={css({ marginBottom: '20px' })}>
<h4
className={css({
fontSize: '18px',
color: 'green.600',
marginBottom: '8px',
})}
>
✅ Strengths:
</h4>
<ul
className={css({
textAlign: 'left',
color: 'gray.700',
lineHeight: '1.6',
})}
>
{analysis.strengths.map((strength, index) => (
<li key={index}>{strength}</li>
))}
</ul>
</div>
)}
{analysis.improvements.length > 0 && (
<div>
<h4
className={css({
fontSize: '18px',
color: 'orange.600',
marginBottom: '8px',
})}
>
💡 Areas for Improvement:
</h4>
<ul
className={css({
textAlign: 'left',
color: 'gray.700',
lineHeight: '1.6',
})}
>
{analysis.improvements.map((improvement, index) => (
<li key={index}>{improvement}</li>
))}
</ul>
</div>
)}
</div>
{/* Action Buttons */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
gap: '20px',
flexWrap: 'wrap',
})}
>
<button
className={css({
background: 'linear-gradient(135deg, #667eea, #764ba2)',
color: 'white',
border: 'none',
borderRadius: '50px',
padding: '16px 32px',
fontSize: '18px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.3s ease',
boxShadow: '0 6px 20px rgba(102, 126, 234, 0.4)',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(102, 126, 234, 0.6)',
},
})}
onClick={resetGame}
>
🎮 Play Again
</button>
<button
className={css({
background: 'linear-gradient(135deg, #a78bfa, #8b5cf6)',
color: 'white',
border: 'none',
borderRadius: '50px',
padding: '16px 32px',
fontSize: '18px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.3s ease',
boxShadow: '0 6px 20px rgba(167, 139, 250, 0.4)',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(167, 139, 250, 0.6)',
},
})}
onClick={() => {
console.log('🔄 ResultsPhase: Navigating to games with Next.js router (no page reload)')
router.push('/games')
}}
>
🏠 Back to Games
</button>
</div>
</div>
)
}

View File

@@ -1,565 +0,0 @@
'use client'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { generateGameCards } from '../utils/cardGeneration'
// Add bounce animation for the start button
const bounceAnimation = `
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
`
// Inject animation styles
if (typeof document !== 'undefined' && !document.getElementById('setup-animations')) {
const style = document.createElement('style')
style.id = 'setup-animations'
style.textContent = bounceAnimation
document.head.appendChild(style)
}
export function SetupPhase() {
const { state, setGameType, setDifficulty, dispatch, activePlayers } = useMemoryPairs()
const { activePlayerCount, gameMode: globalGameMode } = useGameMode()
const handleStartGame = () => {
const cards = generateGameCards(state.gameType, state.difficulty)
dispatch({ type: 'START_GAME', cards, activePlayers })
}
const getButtonStyles = (
isSelected: boolean,
variant: 'primary' | 'secondary' | 'difficulty' = 'primary'
) => {
const baseStyles = {
border: 'none',
borderRadius: { base: '12px', md: '16px' },
padding: { base: '12px 16px', sm: '14px 20px', md: '16px 24px' },
fontSize: { base: '14px', sm: '15px', md: '16px' },
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
minWidth: { base: '120px', sm: '140px', md: '160px' },
textAlign: 'center' as const,
position: 'relative' as const,
overflow: 'hidden' as const,
textShadow: isSelected ? '0 1px 2px rgba(0,0,0,0.2)' : 'none',
transform: 'translateZ(0)', // Enable GPU acceleration
}
if (variant === 'difficulty') {
return css({
...baseStyles,
background: isSelected
? 'linear-gradient(135deg, #ff6b6b, #ee5a24)'
: 'linear-gradient(135deg, #f8f9fa, #e9ecef)',
color: isSelected ? 'white' : '#495057',
boxShadow: isSelected
? '0 8px 25px rgba(255, 107, 107, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)'
: '0 2px 8px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8)',
_hover: {
transform: 'translateY(-3px) scale(1.02)',
boxShadow: isSelected
? '0 12px 35px rgba(255, 107, 107, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)'
: '0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)',
},
_active: {
transform: 'translateY(-1px) scale(1.01)',
},
})
}
if (variant === 'secondary') {
return css({
...baseStyles,
background: isSelected
? 'linear-gradient(135deg, #a78bfa, #8b5cf6)'
: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
color: isSelected ? 'white' : '#475569',
boxShadow: isSelected
? '0 8px 25px rgba(167, 139, 250, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)'
: '0 2px 8px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8)',
_hover: {
transform: 'translateY(-3px) scale(1.02)',
boxShadow: isSelected
? '0 12px 35px rgba(167, 139, 250, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)'
: '0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)',
},
_active: {
transform: 'translateY(-1px) scale(1.01)',
},
})
}
// Primary variant
return css({
...baseStyles,
background: isSelected
? 'linear-gradient(135deg, #667eea, #764ba2)'
: 'linear-gradient(135deg, #ffffff, #f1f5f9)',
color: isSelected ? 'white' : '#334155',
boxShadow: isSelected
? '0 8px 25px rgba(102, 126, 234, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)'
: '0 2px 8px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8)',
_hover: {
transform: 'translateY(-3px) scale(1.02)',
boxShadow: isSelected
? '0 12px 35px rgba(102, 126, 234, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)'
: '0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)',
},
_active: {
transform: 'translateY(-1px) scale(1.01)',
},
})
}
return (
<div
className={css({
textAlign: 'center',
padding: { base: '12px 16px', sm: '16px 20px', md: '20px' },
maxWidth: '800px',
margin: '0 auto',
display: 'flex',
flexDirection: 'column',
minHeight: 0, // Allow shrinking
overflow: 'auto', // Enable scrolling if needed
})}
>
<div
className={css({
display: 'grid',
gap: { base: '8px', sm: '12px', md: '16px' },
margin: '0 auto',
flex: 1,
minHeight: 0, // Allow shrinking
})}
>
{/* Warning if no players */}
{activePlayerCount === 0 && (
<div
className={css({
p: '4',
background: 'rgba(239, 68, 68, 0.1)',
border: '2px solid',
borderColor: 'red.300',
rounded: 'xl',
textAlign: 'center',
})}
>
<p
className={css({
color: 'red.700',
fontSize: { base: '14px', md: '16px' },
fontWeight: 'bold',
})}
>
Go back to the arcade to select players before starting the game
</p>
</div>
)}
{/* Game Type Selection */}
<div>
<label
className={css({
display: 'block',
fontSize: { base: '16px', sm: '18px', md: '20px' },
fontWeight: 'bold',
marginBottom: { base: '12px', md: '16px' },
color: 'gray.700',
})}
>
Game Type
</label>
<div
className={css({
display: 'grid',
gridTemplateColumns: {
base: '1fr',
sm: 'repeat(2, 1fr)',
},
gap: { base: '8px', sm: '10px', md: '12px' },
justifyItems: 'stretch',
})}
>
<button
className={getButtonStyles(state.gameType === 'abacus-numeral', 'secondary')}
onClick={() => setGameType('abacus-numeral')}
>
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: { base: '4px', md: '6px' },
})}
>
<div
className={css({
fontSize: { base: '20px', sm: '24px', md: '28px' },
display: 'flex',
alignItems: 'center',
gap: { base: '4px', md: '8px' },
})}
>
<span>🧮</span>
<span className={css({ fontSize: { base: '16px', md: '20px' } })}></span>
<span>🔢</span>
</div>
<div
className={css({
fontWeight: 'bold',
fontSize: { base: '12px', sm: '13px', md: '14px' },
})}
>
Abacus-Numeral
</div>
<div
className={css({
fontSize: { base: '10px', sm: '11px', md: '12px' },
opacity: 0.8,
textAlign: 'center',
display: { base: 'none', sm: 'block' },
})}
>
Match visual patterns
<br />
with numbers
</div>
</div>
</button>
<button
className={getButtonStyles(state.gameType === 'complement-pairs', 'secondary')}
onClick={() => setGameType('complement-pairs')}
>
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: { base: '4px', md: '6px' },
})}
>
<div
className={css({
fontSize: { base: '20px', sm: '24px', md: '28px' },
display: 'flex',
alignItems: 'center',
gap: { base: '4px', md: '8px' },
})}
>
<span>🤝</span>
<span className={css({ fontSize: { base: '16px', md: '20px' } })}></span>
<span>🔟</span>
</div>
<div
className={css({
fontWeight: 'bold',
fontSize: { base: '12px', sm: '13px', md: '14px' },
})}
>
Complement Pairs
</div>
<div
className={css({
fontSize: { base: '10px', sm: '11px', md: '12px' },
opacity: 0.8,
textAlign: 'center',
display: { base: 'none', sm: 'block' },
})}
>
Find number friends
<br />
that add to 5 or 10
</div>
</div>
</button>
</div>
<p
className={css({
fontSize: { base: '12px', md: '14px' },
color: 'gray.500',
marginTop: { base: '6px', md: '8px' },
textAlign: 'center',
display: { base: 'none', sm: 'block' },
})}
>
{state.gameType === 'abacus-numeral'
? 'Match abacus representations with their numerical values'
: 'Find pairs of numbers that add up to 5 or 10'}
</p>
</div>
{/* Difficulty Selection */}
<div>
<label
className={css({
display: 'block',
fontSize: { base: '16px', sm: '18px', md: '20px' },
fontWeight: 'bold',
marginBottom: { base: '12px', md: '16px' },
color: 'gray.700',
})}
>
Difficulty ({state.difficulty} pairs)
</label>
<div
className={css({
display: 'grid',
gridTemplateColumns: {
base: 'repeat(2, 1fr)',
sm: 'repeat(4, 1fr)',
},
gap: { base: '8px', sm: '10px', md: '12px' },
justifyItems: 'stretch',
})}
>
{([6, 8, 12, 15] as const).map((difficulty) => {
const difficultyInfo = {
6: {
icon: '🌱',
label: 'Beginner',
description: 'Perfect to start!',
},
8: {
icon: '⚡',
label: 'Medium',
description: 'Getting spicy!',
},
12: {
icon: '🔥',
label: 'Hard',
description: 'Serious challenge!',
},
15: {
icon: '💀',
label: 'Expert',
description: 'Memory master!',
},
}
return (
<button
key={difficulty}
className={getButtonStyles(state.difficulty === difficulty, 'difficulty')}
onClick={() => setDifficulty(difficulty)}
>
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
})}
>
<div className={css({ fontSize: '32px' })}>
{difficultyInfo[difficulty].icon}
</div>
<div className={css({ fontSize: '18px', fontWeight: 'bold' })}>
{difficulty} pairs
</div>
<div className={css({ fontSize: '14px', fontWeight: 'bold' })}>
{difficultyInfo[difficulty].label}
</div>
<div
className={css({
fontSize: '11px',
opacity: 0.9,
textAlign: 'center',
})}
>
{difficultyInfo[difficulty].description}
</div>
</div>
</button>
)
})}
</div>
<p
className={css({
fontSize: '14px',
color: 'gray.500',
marginTop: '8px',
})}
>
{state.difficulty} pairs = {state.difficulty * 2} cards total
</p>
</div>
{/* Multi-Player Timer Setting */}
{activePlayerCount > 1 && (
<div>
<label
className={css({
display: 'block',
fontSize: '20px',
fontWeight: 'bold',
marginBottom: '16px',
color: 'gray.700',
})}
>
Turn Timer
</label>
<div
className={css({
display: 'flex',
gap: '12px',
justifyContent: 'center',
flexWrap: 'wrap',
})}
>
{([15, 30, 45, 60] as const).map((timer) => {
const timerInfo: Record<15 | 30 | 45 | 60, { icon: string; label: string }> = {
15: { icon: '💨', label: 'Lightning' },
30: { icon: '⚡', label: 'Quick' },
45: { icon: '🏃', label: 'Standard' },
60: { icon: '🧘', label: 'Relaxed' },
}
return (
<button
key={timer}
className={getButtonStyles(state.turnTimer === timer, 'secondary')}
onClick={() => dispatch({ type: 'SET_TURN_TIMER', timer })}
>
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
})}
>
<span className={css({ fontSize: '24px' })}>{timerInfo[timer].icon}</span>
<span
className={css({
fontSize: '18px',
fontWeight: 'bold',
})}
>
{timer}s
</span>
<span className={css({ fontSize: '12px', opacity: 0.8 })}>
{timerInfo[timer].label}
</span>
</div>
</button>
)
})}
</div>
<p
className={css({
fontSize: '14px',
color: 'gray.500',
marginTop: '8px',
})}
>
Time limit for each player's turn
</p>
</div>
)}
{/* Start Game Button - Sticky at bottom */}
<div
className={css({
marginTop: 'auto', // Push to bottom
paddingTop: { base: '12px', md: '16px' },
position: 'sticky',
bottom: 0,
background: 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(10px)',
borderTop: '1px solid rgba(0,0,0,0.1)',
margin: '0 -16px -12px -16px', // Extend to edges
padding: { base: '12px 16px', md: '16px' },
})}
>
<button
className={css({
background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
color: 'white',
border: 'none',
borderRadius: { base: '16px', sm: '20px', md: '24px' },
padding: { base: '14px 28px', sm: '16px 32px', md: '18px 36px' },
fontSize: { base: '16px', sm: '18px', md: '20px' },
fontWeight: 'black',
cursor: 'pointer',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
position: 'relative',
overflow: 'hidden',
width: '100%',
_before: {
content: '""',
position: 'absolute',
top: 0,
left: '-100%',
width: '100%',
height: '100%',
background:
'linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)',
transition: 'left 0.6s ease',
},
_hover: {
transform: {
base: 'translateY(-2px)',
md: 'translateY(-3px) scale(1.02)',
},
boxShadow:
'0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
background: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
_before: {
left: '100%',
},
},
_active: {
transform: 'translateY(-1px) scale(1.01)',
},
})}
onClick={handleStartGame}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '6px', md: '8px' },
justifyContent: 'center',
})}
>
<span
className={css({
fontSize: { base: '18px', sm: '20px', md: '24px' },
animation: 'bounce 2s infinite',
})}
>
🚀
</span>
<span>START GAME</span>
<span
className={css({
fontSize: { base: '18px', sm: '20px', md: '24px' },
animation: 'bounce 2s infinite',
animationDelay: '0.5s',
})}
>
🎮
</span>
</div>
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,176 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { PLAYER_EMOJIS } from '../../../../../constants/playerEmojis'
import { EmojiPicker } from '../EmojiPicker'
// Mock the emoji keywords function for testing
vi.mock('emojibase-data/en/data.json', () => ({
default: [
{
emoji: '🐱',
label: 'cat face',
tags: ['cat', 'animal', 'pet', 'cute'],
emoticon: ':)',
},
{
emoji: '🐯',
label: 'tiger face',
tags: ['tiger', 'animal', 'big cat', 'wild'],
emoticon: null,
},
{
emoji: '🤩',
label: 'star-struck',
tags: ['face', 'happy', 'excited', 'star'],
emoticon: null,
},
{
emoji: '🎭',
label: 'performing arts',
tags: ['theater', 'performance', 'drama', 'arts'],
emoticon: null,
},
],
}))
describe('EmojiPicker Search Functionality', () => {
const mockProps = {
currentEmoji: '😀',
onEmojiSelect: vi.fn(),
onClose: vi.fn(),
playerNumber: 1 as const,
}
beforeEach(() => {
vi.clearAllMocks()
})
test('shows all emojis by default (no search)', () => {
render(<EmojiPicker {...mockProps} />)
// Should show default header
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
// Should show emoji count
expect(
screen.getByText(new RegExp(`${PLAYER_EMOJIS.length} characters available`))
).toBeInTheDocument()
// Should show emoji grid
const emojiButtons = screen
.getAllByRole('button')
.filter(
(button) =>
button.textContent &&
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
button.textContent
)
)
expect(emojiButtons.length).toBe(PLAYER_EMOJIS.length)
})
test('shows search results when searching for "cat"', () => {
render(<EmojiPicker {...mockProps} />)
const searchInput = screen.getByPlaceholderText(/Search:/)
fireEvent.change(searchInput, { target: { value: 'cat' } })
// Should show search header
expect(screen.getByText(/🔍 Search Results for "cat"/)).toBeInTheDocument()
// Should show results count
expect(screen.getByText(/✓ \d+ found/)).toBeInTheDocument()
// Should only show cat-related emojis (🐱, 🐯)
const emojiButtons = screen
.getAllByRole('button')
.filter(
(button) =>
button.textContent &&
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
button.textContent
)
)
// Verify only cat emojis are shown
const displayedEmojis = emojiButtons.map((btn) => btn.textContent)
expect(displayedEmojis).toContain('🐱')
expect(displayedEmojis).toContain('🐯')
expect(displayedEmojis).not.toContain('🤩')
expect(displayedEmojis).not.toContain('🎭')
})
test('shows no results message when search has zero matches', () => {
render(<EmojiPicker {...mockProps} />)
const searchInput = screen.getByPlaceholderText(/Search:/)
fireEvent.change(searchInput, { target: { value: 'nonexistentterm' } })
// Should show no results indicator
expect(screen.getByText('✗ No matches')).toBeInTheDocument()
// Should show no results message
expect(screen.getByText(/No emojis found for "nonexistentterm"/)).toBeInTheDocument()
// Should NOT show any emoji buttons
const emojiButtons = screen
.queryAllByRole('button')
.filter(
(button) =>
button.textContent &&
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
button.textContent
)
)
expect(emojiButtons).toHaveLength(0)
})
test('returns to default view when clearing search', () => {
render(<EmojiPicker {...mockProps} />)
const searchInput = screen.getByPlaceholderText(/Search:/)
// Search for something
fireEvent.change(searchInput, { target: { value: 'cat' } })
expect(screen.getByText(/🔍 Search Results for "cat"/)).toBeInTheDocument()
// Clear search
fireEvent.change(searchInput, { target: { value: '' } })
// Should return to default view
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
expect(
screen.getByText(new RegExp(`${PLAYER_EMOJIS.length} characters available`))
).toBeInTheDocument()
// Should show all emojis again
const emojiButtons = screen
.getAllByRole('button')
.filter(
(button) =>
button.textContent &&
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
button.textContent
)
)
expect(emojiButtons.length).toBe(PLAYER_EMOJIS.length)
})
test('clear search button works from no results state', () => {
render(<EmojiPicker {...mockProps} />)
const searchInput = screen.getByPlaceholderText(/Search:/)
// Search for something with no results
fireEvent.change(searchInput, { target: { value: 'nonexistentterm' } })
expect(screen.getByText(/No emojis found/)).toBeInTheDocument()
// Click clear search button
const clearButton = screen.getByText(/Clear search to see all/)
fireEvent.click(clearButton)
// Should return to default view
expect(searchInput).toHaveValue('')
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
})
})

View File

@@ -1,382 +0,0 @@
'use client'
import { createContext, type ReactNode, useContext, useEffect, useReducer } from 'react'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { generateGameCards } from '../utils/cardGeneration'
import { validateMatch } from '../utils/matchValidation'
import type {
GameStatistics,
MemoryPairsAction,
MemoryPairsContextValue,
MemoryPairsState,
PlayerScore,
} from './types'
// Initial state (gameMode removed - now derived from global context)
const initialState: MemoryPairsState = {
// Core game data
cards: [],
gameCards: [],
flippedCards: [],
// Game configuration (gameMode removed)
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
// Game progression
gamePhase: 'setup',
currentPlayer: '', // Will be set to first player ID on START_GAME
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: [],
consecutiveMatches: {},
// Timing
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
// UI state
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
}
// Reducer function
function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction): MemoryPairsState {
switch (action.type) {
// SET_GAME_MODE removed - game mode now derived from global context
case 'SET_GAME_TYPE':
return {
...state,
gameType: action.gameType,
}
case 'SET_DIFFICULTY':
return {
...state,
difficulty: action.difficulty,
totalPairs: action.difficulty,
}
case 'SET_TURN_TIMER':
return {
...state,
turnTimer: action.timer,
}
case 'START_GAME': {
// Initialize scores and consecutive matches for all active players
const scores: PlayerScore = {}
const consecutiveMatches: { [playerId: string]: number } = {}
action.activePlayers.forEach((playerId) => {
scores[playerId] = 0
consecutiveMatches[playerId] = 0
})
return {
...state,
gamePhase: 'playing',
gameCards: action.cards,
cards: action.cards,
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores,
consecutiveMatches,
activePlayers: action.activePlayers,
currentPlayer: action.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
}
}
case 'FLIP_CARD': {
const cardToFlip = state.gameCards.find((card) => card.id === action.cardId)
if (
!cardToFlip ||
cardToFlip.matched ||
state.flippedCards.length >= 2 ||
state.isProcessingMove
) {
return state
}
const newFlippedCards = [...state.flippedCards, cardToFlip]
const newMoveStartTime =
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime: newMoveStartTime,
showMismatchFeedback: false,
}
}
case 'MATCH_FOUND': {
const [card1Id, card2Id] = action.cardIds
const updatedCards = state.gameCards.map((card) => {
if (card.id === card1Id || card.id === card2Id) {
return {
...card,
matched: true,
matchedBy: state.currentPlayer,
}
}
return card
})
const newMatchedPairs = state.matchedPairs + 1
const newScores = {
...state.scores,
[state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1,
}
const newConsecutiveMatches = {
...state.consecutiveMatches,
[state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1,
}
// Check if game is complete
const isGameComplete = newMatchedPairs === state.totalPairs
return {
...state,
gameCards: updatedCards,
matchedPairs: newMatchedPairs,
scores: newScores,
consecutiveMatches: newConsecutiveMatches,
flippedCards: [],
moves: state.moves + 1,
lastMatchedPair: action.cardIds,
gamePhase: isGameComplete ? 'results' : 'playing',
gameEndTime: isGameComplete ? Date.now() : null,
isProcessingMove: false,
// Note: Player keeps turn after successful match in multiplayer mode
}
}
case 'MATCH_FAILED': {
// Player switching is now handled by passing activePlayerCount
return {
...state,
flippedCards: [],
moves: state.moves + 1,
showMismatchFeedback: true,
isProcessingMove: false,
// currentPlayer will be updated by SWITCH_PLAYER action when needed
}
}
case 'SWITCH_PLAYER': {
// Cycle through all active players
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
const nextIndex = (currentIndex + 1) % state.activePlayers.length
// Reset consecutive matches for the player who failed
const newConsecutiveMatches = {
...state.consecutiveMatches,
[state.currentPlayer]: 0,
}
return {
...state,
currentPlayer: state.activePlayers[nextIndex] || state.activePlayers[0],
consecutiveMatches: newConsecutiveMatches,
}
}
case 'ADD_CELEBRATION':
return {
...state,
celebrationAnimations: [...state.celebrationAnimations, action.animation],
}
case 'REMOVE_CELEBRATION':
return {
...state,
celebrationAnimations: state.celebrationAnimations.filter(
(anim) => anim.id !== action.animationId
),
}
case 'SET_PROCESSING':
return {
...state,
isProcessingMove: action.processing,
}
case 'SET_MISMATCH_FEEDBACK':
return {
...state,
showMismatchFeedback: action.show,
}
case 'SHOW_RESULTS':
return {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
flippedCards: [],
}
case 'RESET_GAME':
return {
...initialState,
gameType: state.gameType,
difficulty: state.difficulty,
turnTimer: state.turnTimer,
totalPairs: state.difficulty,
}
case 'UPDATE_TIMER':
// This can be used for any timer-related updates
return state
default:
return state
}
}
// Create context
const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
// Provider component
export function MemoryPairsProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(memoryPairsReducer, initialState)
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
// Handle card matching logic when two cards are flipped
useEffect(() => {
if (state.flippedCards.length === 2 && !state.isProcessingMove) {
dispatch({ type: 'SET_PROCESSING', processing: true })
const [card1, card2] = state.flippedCards
const matchResult = validateMatch(card1, card2)
// Delay to allow card flip animation
setTimeout(() => {
if (matchResult.isValid) {
dispatch({ type: 'MATCH_FOUND', cardIds: [card1.id, card2.id] })
} else {
dispatch({ type: 'MATCH_FAILED', cardIds: [card1.id, card2.id] })
// Switch player only in multiplayer mode
if (gameMode === 'multiplayer') {
dispatch({ type: 'SWITCH_PLAYER' })
}
}
}, 1000) // Give time to see both cards
}
}, [state.flippedCards, state.isProcessingMove, gameMode])
// Auto-hide mismatch feedback
useEffect(() => {
if (state.showMismatchFeedback) {
const timeout = setTimeout(() => {
dispatch({ type: 'SET_MISMATCH_FEEDBACK', show: false })
}, 2000)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = (cardId: string): boolean => {
if (!isGameActive || state.isProcessingMove) return false
const card = state.gameCards.find((c) => c.id === cardId)
if (!card || card.matched) return false
// Can't flip if already flipped
if (state.flippedCards.some((c) => c.id === cardId)) return false
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) return false
return true
}
const currentGameStatistics: GameStatistics = {
totalMoves: state.moves,
matchedPairs: state.matchedPairs,
totalPairs: state.totalPairs,
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
averageTimePerMove:
state.moves > 0 && state.gameStartTime
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
: 0,
}
// Action creators
const startGame = () => {
const cards = generateGameCards(state.gameType, state.difficulty)
dispatch({ type: 'START_GAME', cards, activePlayers })
}
const flipCard = (cardId: string) => {
if (!canFlipCard(cardId)) return
dispatch({ type: 'FLIP_CARD', cardId })
}
const resetGame = () => {
dispatch({ type: 'RESET_GAME' })
}
// setGameMode removed - game mode is now derived from global context
const setGameType = (gameType: typeof state.gameType) => {
dispatch({ type: 'SET_GAME_TYPE', gameType })
}
const setDifficulty = (difficulty: typeof state.difficulty) => {
dispatch({ type: 'SET_DIFFICULTY', difficulty })
}
const contextValue: MemoryPairsContextValue = {
state: { ...state, gameMode }, // Add derived gameMode to state
dispatch,
isGameActive,
canFlipCard,
currentGameStatistics,
startGame,
flipCard,
resetGame,
setGameType,
setDifficulty,
exitSession: () => {}, // No-op for non-arcade mode
gameMode, // Expose derived gameMode
activePlayers, // Expose active players
}
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
}
// Hook to use the context
export function useMemoryPairs(): MemoryPairsContextValue {
const context = useContext(MemoryPairsContext)
if (!context) {
throw new Error('useMemoryPairs must be used within a MemoryPairsProvider')
}
return context
}

View File

@@ -1,180 +0,0 @@
// TypeScript interfaces for Memory Pairs Challenge game
export type GameMode = 'single' | 'multiplayer'
export type GameType = 'abacus-numeral' | 'complement-pairs'
export type GamePhase = 'setup' | 'playing' | 'results'
export type CardType = 'abacus' | 'number' | 'complement'
export type Difficulty = 6 | 8 | 12 | 15 // Number of pairs
export type Player = string // Player ID (UUID)
export type TargetSum = 5 | 10 | 20
export interface GameCard {
id: string
type: CardType
number: number
complement?: number // For complement pairs
targetSum?: TargetSum // For complement pairs
matched: boolean
matchedBy?: Player // For two-player mode
element?: HTMLElement | null // For animations
}
export interface PlayerScore {
[playerId: string]: number
}
export interface CelebrationAnimation {
id: string
type: 'match' | 'win' | 'confetti'
x: number
y: number
timestamp: number
}
export interface GameStatistics {
totalMoves: number
matchedPairs: number
totalPairs: number
gameTime: number
accuracy: number // Percentage of successful matches
averageTimePerMove: number
}
export interface PlayerMetadata {
id: string // Player ID
name: string
emoji: string
userId: string // Which user owns this player
color?: string
}
export interface GameConfiguration {
gameType: GameType
difficulty: Difficulty
turnTimer: number
}
export interface MemoryPairsState {
// Core game data
cards: GameCard[]
gameCards: GameCard[]
flippedCards: GameCard[]
// Game configuration (gameMode removed - now derived from global context)
gameType: GameType
difficulty: Difficulty
turnTimer: number // Seconds for two-player mode
// Paused game state - for Resume functionality
originalConfig?: GameConfiguration // Config when game started - used to detect changes
pausedGamePhase?: 'playing' | 'results' // Set when GO_TO_SETUP called from active game
pausedGameState?: {
// Snapshot of game state when paused
gameCards: GameCard[]
currentPlayer: Player
matchedPairs: number
moves: number
scores: PlayerScore
activePlayers: Player[]
playerMetadata: { [playerId: string]: PlayerMetadata }
consecutiveMatches: { [playerId: string]: number }
gameStartTime: number | null
}
// Game progression
gamePhase: GamePhase
currentPlayer: Player
matchedPairs: number
totalPairs: number
moves: number
scores: PlayerScore
activePlayers: Player[] // Track active player IDs
playerMetadata: { [playerId: string]: PlayerMetadata } // Player metadata snapshot for cross-user visibility
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
// 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
// Hover state for networked presence
playerHovers: { [playerId: string]: string | null } // playerId -> cardId (or null if not hovering)
}
export type MemoryPairsAction =
| { type: 'SET_GAME_TYPE'; gameType: GameType }
| { type: 'SET_DIFFICULTY'; difficulty: Difficulty }
| { type: 'SET_TURN_TIMER'; timer: number }
| { type: 'START_GAME'; cards: GameCard[]; activePlayers: Player[] }
| { type: 'FLIP_CARD'; cardId: string }
| { type: 'MATCH_FOUND'; cardIds: [string, string] }
| { type: 'MATCH_FAILED'; cardIds: [string, string] }
| { type: 'SWITCH_PLAYER' }
| { type: 'ADD_CELEBRATION'; animation: CelebrationAnimation }
| { type: 'REMOVE_CELEBRATION'; animationId: string }
| { type: 'SHOW_RESULTS' }
| { type: 'RESET_GAME' }
| { type: 'SET_PROCESSING'; processing: boolean }
| { type: 'SET_MISMATCH_FEEDBACK'; show: boolean }
| { type: 'UPDATE_TIMER' }
export interface MemoryPairsContextValue {
state: MemoryPairsState & { gameMode: GameMode } // gameMode added as computed property
dispatch: React.Dispatch<MemoryPairsAction>
// Computed values
isGameActive: boolean
canFlipCard: (cardId: string) => boolean
currentGameStatistics: GameStatistics
gameMode: GameMode // Derived from global context
activePlayers: Player[] // Active player IDs from arena
hasConfigChanged: boolean // True if current config differs from originalConfig
canResumeGame: boolean // True if there's a paused game and config hasn't changed
// Actions
startGame: () => void
resumeGame: () => void
flipCard: (cardId: string) => void
resetGame: () => void
setGameType: (type: GameType) => void
setDifficulty: (difficulty: Difficulty) => void
setTurnTimer: (timer: number) => void
hoverCard: (cardId: string | null) => void // Send hover state for networked presence
goToSetup: () => void
exitSession: () => void // Exit arcade session (no-op for non-arcade mode)
}
// Utility types for component props
export interface GameCardProps {
card: GameCard
isFlipped: boolean
isMatched: boolean
onClick: () => void
disabled?: boolean
}
export interface PlayerIndicatorProps {
player: Player
isActive: boolean
score: number
name?: string
}
export interface GameGridProps {
cards: GameCard[]
onCardClick: (cardId: string) => void
disabled?: boolean
}
export interface MatchValidationResult {
isValid: boolean
reason?: string
type: 'abacus-numeral' | 'complement' | 'invalid'
}

View File

@@ -1,10 +0,0 @@
import { MemoryPairsGame } from './components/MemoryPairsGame'
import { MemoryPairsProvider } from './context/MemoryPairsContext'
export default function MatchingPage() {
return (
<MemoryPairsProvider>
<MemoryPairsGame />
</MemoryPairsProvider>
)
}

View File

@@ -1,194 +0,0 @@
import type { Difficulty, GameCard, GameType } from '../context/types'
// Utility function to generate unique random numbers
function generateUniqueNumbers(count: number, options: { min: number; max: number }): number[] {
const numbers = new Set<number>()
const { min, max } = options
while (numbers.size < count) {
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min
numbers.add(randomNum)
}
return Array.from(numbers)
}
// Utility function to shuffle an array
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}
// Generate cards for abacus-numeral game mode
export function generateAbacusNumeralCards(pairs: Difficulty): GameCard[] {
// Generate unique numbers based on difficulty
// For easier games, use smaller numbers; for harder games, use larger ranges
const numberRanges: Record<Difficulty, { min: number; max: number }> = {
6: { min: 1, max: 50 }, // 6 pairs: 1-50
8: { min: 1, max: 100 }, // 8 pairs: 1-100
12: { min: 1, max: 200 }, // 12 pairs: 1-200
15: { min: 1, max: 300 }, // 15 pairs: 1-300
}
const range = numberRanges[pairs]
const numbers = generateUniqueNumbers(pairs, range)
const cards: GameCard[] = []
numbers.forEach((number) => {
// Abacus representation card
cards.push({
id: `abacus_${number}`,
type: 'abacus',
number,
matched: false,
})
// Numerical representation card
cards.push({
id: `number_${number}`,
type: 'number',
number,
matched: false,
})
})
return shuffleArray(cards)
}
// Generate cards for complement pairs game mode
export function generateComplementCards(pairs: Difficulty): GameCard[] {
// Define complement pairs for friends of 5 and friends of 10
const complementPairs = [
// Friends of 5
{ pair: [0, 5], targetSum: 5 as const },
{ pair: [1, 4], targetSum: 5 as const },
{ pair: [2, 3], targetSum: 5 as const },
// Friends of 10
{ pair: [0, 10], targetSum: 10 as const },
{ pair: [1, 9], targetSum: 10 as const },
{ pair: [2, 8], targetSum: 10 as const },
{ pair: [3, 7], targetSum: 10 as const },
{ pair: [4, 6], targetSum: 10 as const },
{ pair: [5, 5], targetSum: 10 as const },
// Additional pairs for higher difficulties
{ pair: [6, 4], targetSum: 10 as const },
{ pair: [7, 3], targetSum: 10 as const },
{ pair: [8, 2], targetSum: 10 as const },
{ pair: [9, 1], targetSum: 10 as const },
{ pair: [10, 0], targetSum: 10 as const },
// More challenging pairs (can be used for expert mode)
{ pair: [11, 9], targetSum: 20 as const },
{ pair: [12, 8], targetSum: 20 as const },
]
// Select the required number of complement pairs
const selectedPairs = complementPairs.slice(0, pairs)
const cards: GameCard[] = []
selectedPairs.forEach(({ pair: [num1, num2], targetSum }, index) => {
// First number in the pair
cards.push({
id: `comp1_${index}_${num1}`,
type: 'complement',
number: num1,
complement: num2,
targetSum,
matched: false,
})
// Second number in the pair
cards.push({
id: `comp2_${index}_${num2}`,
type: 'complement',
number: num2,
complement: num1,
targetSum,
matched: false,
})
})
return shuffleArray(cards)
}
// Main card generation function
export function generateGameCards(gameType: GameType, difficulty: Difficulty): GameCard[] {
switch (gameType) {
case 'abacus-numeral':
return generateAbacusNumeralCards(difficulty)
case 'complement-pairs':
return generateComplementCards(difficulty)
default:
throw new Error(`Unknown game type: ${gameType}`)
}
}
// Utility function to get responsive grid configuration based on difficulty and screen size
export function getGridConfiguration(difficulty: Difficulty) {
const configs: Record<
Difficulty,
{
totalCards: number
// Orientation-optimized responsive columns
mobileColumns: number // Portrait mobile
tabletColumns: number // Tablet
desktopColumns: number // Desktop/landscape
landscapeColumns: number // Landscape mobile/tablet
cardSize: { width: string; height: string }
gridTemplate: string
}
> = {
6: {
totalCards: 12,
mobileColumns: 3, // 3x4 grid in portrait
tabletColumns: 4, // 4x3 grid on tablet
desktopColumns: 4, // 4x3 grid on desktop
landscapeColumns: 6, // 6x2 grid in landscape
cardSize: { width: '140px', height: '180px' },
gridTemplate: 'repeat(3, 1fr)',
},
8: {
totalCards: 16,
mobileColumns: 3, // 3x6 grid in portrait (some spillover)
tabletColumns: 4, // 4x4 grid on tablet
desktopColumns: 4, // 4x4 grid on desktop
landscapeColumns: 6, // 6x3 grid in landscape (some spillover)
cardSize: { width: '120px', height: '160px' },
gridTemplate: 'repeat(3, 1fr)',
},
12: {
totalCards: 24,
mobileColumns: 3, // 3x8 grid in portrait
tabletColumns: 4, // 4x6 grid on tablet
desktopColumns: 6, // 6x4 grid on desktop
landscapeColumns: 6, // 6x4 grid in landscape (changed from 8x3)
cardSize: { width: '100px', height: '140px' },
gridTemplate: 'repeat(3, 1fr)',
},
15: {
totalCards: 30,
mobileColumns: 3, // 3x10 grid in portrait
tabletColumns: 5, // 5x6 grid on tablet
desktopColumns: 6, // 6x5 grid on desktop
landscapeColumns: 10, // 10x3 grid in landscape
cardSize: { width: '90px', height: '120px' },
gridTemplate: 'repeat(3, 1fr)',
},
}
return configs[difficulty]
}
// Generate a unique ID for cards
export function generateCardId(type: string, identifier: string | number): string {
return `${type}_${identifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}

View File

@@ -1,331 +0,0 @@
import type { GameStatistics, MemoryPairsState, Player } from '../context/types'
// Calculate final game score based on multiple factors
export function calculateFinalScore(
matchedPairs: number,
totalPairs: number,
moves: number,
gameTime: number,
difficulty: number,
gameMode: 'single' | 'two-player'
): number {
// Base score for completing pairs
const baseScore = matchedPairs * 100
// Efficiency bonus (fewer moves = higher bonus)
const idealMoves = totalPairs * 2 // Perfect game would be 2 moves per pair
const efficiency = idealMoves / Math.max(moves, idealMoves)
const efficiencyBonus = Math.round(baseScore * efficiency * 0.5)
// Time bonus (faster completion = higher bonus)
const timeInMinutes = gameTime / (1000 * 60)
const timeBonus = Math.max(0, Math.round((1000 * difficulty) / timeInMinutes))
// Difficulty multiplier
const difficultyMultiplier = 1 + (difficulty - 6) * 0.1
// Two-player mode bonus
const modeMultiplier = gameMode === 'two-player' ? 1.2 : 1.0
const finalScore = Math.round(
(baseScore + efficiencyBonus + timeBonus) * difficultyMultiplier * modeMultiplier
)
return Math.max(0, finalScore)
}
// Calculate star rating (1-5 stars) based on performance
export function calculateStarRating(
accuracy: number,
efficiency: number,
gameTime: number,
difficulty: number
): number {
// Normalize time score (assuming reasonable time ranges)
const expectedTime = difficulty * 30000 // 30 seconds per pair as baseline
const timeScore = Math.max(0, Math.min(100, (expectedTime / gameTime) * 100))
// Weighted average of different factors
const overallScore = accuracy * 0.4 + efficiency * 0.4 + timeScore * 0.2
// Convert to stars
if (overallScore >= 90) return 5
if (overallScore >= 80) return 4
if (overallScore >= 70) return 3
if (overallScore >= 60) return 2
return 1
}
// Get achievement badges based on performance
export interface Achievement {
id: string
name: string
description: string
icon: string
earned: boolean
}
export function getAchievements(
state: MemoryPairsState,
gameMode: 'single' | 'multiplayer'
): Achievement[] {
const { matchedPairs, totalPairs, moves, scores, gameStartTime, gameEndTime } = state
const accuracy = moves > 0 ? (matchedPairs / moves) * 100 : 0
const gameTime = gameStartTime && gameEndTime ? gameEndTime - gameStartTime : 0
const gameTimeInSeconds = gameTime / 1000
const achievements: Achievement[] = [
{
id: 'perfect_game',
name: 'Perfect Memory',
description: 'Complete a game with 100% accuracy',
icon: '🧠',
earned: matchedPairs === totalPairs && moves === totalPairs * 2,
},
{
id: 'speed_demon',
name: 'Speed Demon',
description: 'Complete a game in under 2 minutes',
icon: '⚡',
earned: gameTimeInSeconds > 0 && gameTimeInSeconds < 120 && matchedPairs === totalPairs,
},
{
id: 'accuracy_ace',
name: 'Accuracy Ace',
description: 'Achieve 90% accuracy or higher',
icon: '🎯',
earned: accuracy >= 90 && matchedPairs === totalPairs,
},
{
id: 'marathon_master',
name: 'Marathon Master',
description: 'Complete the hardest difficulty (15 pairs)',
icon: '🏃',
earned: totalPairs === 15 && matchedPairs === totalPairs,
},
{
id: 'complement_champion',
name: 'Complement Champion',
description: 'Master complement pairs mode',
icon: '🤝',
earned:
state.gameType === 'complement-pairs' && matchedPairs === totalPairs && accuracy >= 85,
},
{
id: 'two_player_triumph',
name: 'Two-Player Triumph',
description: 'Win a two-player game',
icon: '👥',
earned:
gameMode === 'multiplayer' &&
matchedPairs === totalPairs &&
Object.keys(scores).length > 1 &&
Math.max(...Object.values(scores)) > 0,
},
{
id: 'shutout_victory',
name: 'Shutout Victory',
description: 'Win a two-player game without opponent scoring',
icon: '🛡️',
earned:
gameMode === 'multiplayer' &&
matchedPairs === totalPairs &&
Object.values(scores).some((score) => score === totalPairs) &&
Object.values(scores).some((score) => score === 0),
},
{
id: 'comeback_kid',
name: 'Comeback Kid',
description: 'Win after being behind by 3+ points',
icon: '🔄',
earned: false, // This would need more complex tracking during the game
},
{
id: 'first_timer',
name: 'First Timer',
description: 'Complete your first game',
icon: '🌟',
earned: matchedPairs === totalPairs,
},
{
id: 'consistency_king',
name: 'Consistency King',
description: 'Achieve 80%+ accuracy in 5 consecutive games',
icon: '👑',
earned: false, // This would need persistent game history
},
]
return achievements
}
// Get performance metrics and analysis
export function getPerformanceAnalysis(state: MemoryPairsState): {
statistics: GameStatistics
grade: 'A+' | 'A' | 'B+' | 'B' | 'C+' | 'C' | 'D' | 'F'
strengths: string[]
improvements: string[]
starRating: number
} {
const { matchedPairs, totalPairs, moves, difficulty, gameStartTime, gameEndTime } = state
const gameTime = gameStartTime && gameEndTime ? gameEndTime - gameStartTime : 0
// Calculate statistics
const accuracy = moves > 0 ? (matchedPairs / moves) * 100 : 0
const averageTimePerMove = moves > 0 ? gameTime / moves : 0
const statistics: GameStatistics = {
totalMoves: moves,
matchedPairs,
totalPairs,
gameTime,
accuracy,
averageTimePerMove,
}
// Calculate efficiency (ideal vs actual moves)
const idealMoves = totalPairs * 2
const efficiency = (idealMoves / Math.max(moves, idealMoves)) * 100
// Determine grade
let grade: 'A+' | 'A' | 'B+' | 'B' | 'C+' | 'C' | 'D' | 'F' = 'F'
if (accuracy >= 95 && efficiency >= 90) grade = 'A+'
else if (accuracy >= 90 && efficiency >= 85) grade = 'A'
else if (accuracy >= 85 && efficiency >= 80) grade = 'B+'
else if (accuracy >= 80 && efficiency >= 75) grade = 'B'
else if (accuracy >= 75 && efficiency >= 70) grade = 'C+'
else if (accuracy >= 70 && efficiency >= 65) grade = 'C'
else if (accuracy >= 60 && efficiency >= 50) grade = 'D'
// Calculate star rating
const starRating = calculateStarRating(accuracy, efficiency, gameTime, difficulty)
// Analyze strengths and areas for improvement
const strengths: string[] = []
const improvements: string[] = []
if (accuracy >= 90) {
strengths.push('Excellent memory and pattern recognition')
} else if (accuracy < 70) {
improvements.push('Focus on remembering card positions more carefully')
}
if (efficiency >= 85) {
strengths.push('Very efficient with minimal unnecessary moves')
} else if (efficiency < 60) {
improvements.push('Try to reduce random guessing and use memory strategies')
}
const avgTimePerMoveSeconds = averageTimePerMove / 1000
if (avgTimePerMoveSeconds < 3) {
strengths.push('Quick decision making')
} else if (avgTimePerMoveSeconds > 8) {
improvements.push('Practice to improve decision speed')
}
if (difficulty >= 12) {
strengths.push('Tackled challenging difficulty levels')
}
if (state.gameType === 'complement-pairs' && accuracy >= 80) {
strengths.push('Strong mathematical complement skills')
}
// Fallback messages
if (strengths.length === 0) {
strengths.push('Keep practicing to improve your skills!')
}
if (improvements.length === 0) {
improvements.push('Great job! Continue challenging yourself with harder difficulties.')
}
return {
statistics,
grade,
strengths,
improvements,
starRating,
}
}
// Format time duration for display
export function formatGameTime(milliseconds: number): string {
const seconds = Math.floor(milliseconds / 1000)
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (minutes > 0) {
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
return `${remainingSeconds}s`
}
// Get two-player game winner
// @deprecated Use getMultiplayerWinner instead which supports N players
export function getTwoPlayerWinner(
state: MemoryPairsState,
activePlayers: Player[]
): {
winner: Player | 'tie'
winnerScore: number
loserScore: number
margin: number
} {
const { scores } = state
const [player1, player2] = activePlayers
if (!player1 || !player2) {
throw new Error('getTwoPlayerWinner requires at least 2 active players')
}
const score1 = scores[player1] || 0
const score2 = scores[player2] || 0
if (score1 > score2) {
return {
winner: player1,
winnerScore: score1,
loserScore: score2,
margin: score1 - score2,
}
} else if (score2 > score1) {
return {
winner: player2,
winnerScore: score2,
loserScore: score1,
margin: score2 - score1,
}
} else {
return {
winner: 'tie',
winnerScore: score1,
loserScore: score2,
margin: 0,
}
}
}
// Get multiplayer game winner (supports N players)
export function getMultiplayerWinner(
state: MemoryPairsState,
activePlayers: Player[]
): {
winners: Player[]
winnerScore: number
scores: { [playerId: string]: number }
isTie: boolean
} {
const { scores } = state
// Find the highest score
const maxScore = Math.max(...activePlayers.map((playerId) => scores[playerId] || 0))
// Find all players with the highest score
const winners = activePlayers.filter((playerId) => (scores[playerId] || 0) === maxScore)
return {
winners,
winnerScore: maxScore,
scores,
isTie: winners.length > 1,
}
}

View File

@@ -20,11 +20,10 @@ function GamesPageContent() {
const _handleGameClick = (gameType: string) => {
// Navigate directly to games using the centralized game mode with Next.js router
// Note: battle-arena has been removed - now handled by game registry as "matching"
console.log('🔄 GamesPage: Navigating with Next.js router (no page reload)')
if (gameType === 'memory-quiz') {
router.push('/games/memory-quiz')
} else if (gameType === 'battle-arena') {
router.push('/games/matching')
}
}

View File

@@ -1,6 +1,6 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
import { type ReactNode, useCallback, useEffect, useMemo, createContext, useContext } from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
@@ -9,13 +9,15 @@ import {
buildPlayerOwnershipFromRoomData,
} from '@/lib/arcade/player-ownership.client'
import type { GameMove } from '@/lib/arcade/validation'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { generateGameCards } from '../utils/cardGeneration'
import { MemoryPairsContext } from './MemoryPairsContext'
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
import { useGameMode } from '@/contexts/GameModeContext'
import { generateGameCards } from './utils/cardGeneration'
import type { GameMode, GameStatistics, MatchingContextValue, MatchingState, MatchingMove } from './types'
// Create context for Matching game
const MatchingContext = createContext<MatchingContextValue | null>(null)
// Initial state
const initialState: MemoryPairsState = {
const initialState: MatchingState = {
cards: [],
gameCards: [],
flippedCards: [],
@@ -51,26 +53,27 @@ const initialState: MemoryPairsState = {
* Optimistic move application (client-side prediction)
* The server will validate and send back the authoritative state
*/
function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): MemoryPairsState {
switch (move.type) {
function applyMoveOptimistically(state: MatchingState, move: GameMove): MatchingState {
const typedMove = move as MatchingMove
switch (typedMove.type) {
case 'START_GAME':
// Generate cards and initialize game
return {
...state,
gamePhase: 'playing',
gameCards: move.data.cards,
cards: move.data.cards,
gameCards: typedMove.data.cards,
cards: typedMove.data.cards,
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: move.data.activePlayers.reduce(
scores: typedMove.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: typedMove.data.activePlayers.reduce(
(acc: any, p: string) => ({ ...acc, [p]: 0 }),
{}
),
activePlayers: move.data.activePlayers,
playerMetadata: move.data.playerMetadata || {}, // Include player metadata
currentPlayer: move.data.activePlayers[0] || '',
activePlayers: typedMove.data.activePlayers,
playerMetadata: typedMove.data.playerMetadata || {}, // Include player metadata
currentPlayer: typedMove.data.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
@@ -94,7 +97,7 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
const gameCards = state.gameCards || []
const flippedCards = state.flippedCards || []
const card = gameCards.find((c) => c.id === move.data.cardId)
const card = gameCards.find((c) => c.id === typedMove.data.cardId)
if (!card) return state
const newFlippedCards = [...flippedCards, card]
@@ -173,7 +176,7 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
case 'SET_CONFIG': {
// Update configuration field optimistically
const { field, value } = move.data as { field: string; value: any }
const { field, value } = typedMove.data
const clearPausedGame = !!state.pausedGamePhase
return {
@@ -223,7 +226,7 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
...state,
playerHovers: {
...state.playerHovers,
[move.playerId]: move.data.cardId,
[typedMove.playerId]: typedMove.data.cardId,
},
}
}
@@ -236,14 +239,14 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
// Provider component for ROOM-BASED play (with network sync)
// NOTE: This provider should ONLY be used for room-based multiplayer games.
// For arcade sessions without rooms, use LocalMemoryPairsProvider instead.
export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
export function MatchingProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData() // Fetch room data for room-based play
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
const activePlayers = Array.from(activePlayerIds) as string[]
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
@@ -251,7 +254,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
// Track roomData.gameConfig changes
useEffect(() => {
console.log(
'[RoomMemoryPairsProvider] roomData.gameConfig changed:',
'[MatchingProvider] roomData.gameConfig changed:',
JSON.stringify(
{
gameConfig: roomData?.gameConfig,
@@ -269,7 +272,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
console.log(
'[RoomMemoryPairsProvider] Loading settings from database:',
'[MatchingProvider] Loading settings from database:',
JSON.stringify(
{
gameConfig,
@@ -281,19 +284,19 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
)
if (!gameConfig) {
console.log('[RoomMemoryPairsProvider] No gameConfig, using initialState')
console.log('[MatchingProvider] No gameConfig, using initialState')
return initialState
}
// Get settings for this specific game (matching)
const savedConfig = gameConfig.matching as Record<string, any> | null | undefined
console.log(
'[RoomMemoryPairsProvider] Saved config for matching:',
'[MatchingProvider] Saved config for matching:',
JSON.stringify(savedConfig, null, 2)
)
if (!savedConfig) {
console.log('[RoomMemoryPairsProvider] No saved config for matching, using initialState')
console.log('[MatchingProvider] No saved config for matching, using initialState')
return initialState
}
@@ -305,7 +308,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
turnTimer: savedConfig.turnTimer ?? initialState.turnTimer,
}
console.log(
'[RoomMemoryPairsProvider] Merged state:',
'[MatchingProvider] Merged state:',
JSON.stringify(
{
gameType: merged.gameType,
@@ -326,7 +329,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
sendMove,
connected: _connected,
exitSession,
} = useArcadeSession<MemoryPairsState>({
} = useArcadeSession<MatchingState>({
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
initialState: mergedInitialState,
@@ -479,7 +482,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
// Use centralized utility to build metadata
return buildPlayerMetadataUtil(playerIds, playerOwnership, players, viewerId)
return buildPlayerMetadataUtil(playerIds, playerOwnership, players, viewerId ?? undefined)
},
[players, roomData, viewerId]
)
@@ -488,7 +491,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const startGame = useCallback(() => {
// Must have at least one active player
if (activePlayers.length === 0) {
console.error('[RoomMemoryPairs] Cannot start game without active players')
console.error('[MatchingProvider] Cannot start game without active players')
return
}
@@ -499,7 +502,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty)
// Use first active player as playerId for START_GAME move
const firstPlayer = activePlayers[0]
const firstPlayer = activePlayers[0] as string
sendMove({
type: 'START_GAME',
playerId: firstPlayer,
@@ -543,7 +546,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const resetGame = useCallback(() => {
// Must have at least one active player
if (activePlayers.length === 0) {
console.error('[RoomMemoryPairs] Cannot reset game without active players')
console.error('[MatchingProvider] Cannot reset game without active players')
return
}
@@ -553,7 +556,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty)
// Use first active player as playerId for START_GAME move
const firstPlayer = activePlayers[0]
const firstPlayer = activePlayers[0] as string
sendMove({
type: 'START_GAME',
playerId: firstPlayer,
@@ -568,10 +571,10 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const setGameType = useCallback(
(gameType: typeof state.gameType) => {
console.log('[RoomMemoryPairsProvider] setGameType called:', gameType)
console.log('[MatchingProvider] setGameType called:', gameType)
// Use first active player as playerId, or empty string if none
const playerId = activePlayers[0] || ''
const playerId = (activePlayers[0] as string) || ''
sendMove({
type: 'SET_CONFIG',
playerId,
@@ -592,7 +595,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
},
}
console.log(
'[RoomMemoryPairsProvider] Saving gameType to database:',
'[MatchingProvider] Saving gameType to database:',
JSON.stringify(
{
roomId: roomData.id,
@@ -607,7 +610,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryPairsProvider] Cannot save gameType - no roomData.id')
console.warn('[MatchingProvider] Cannot save gameType - no roomData.id')
}
},
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
@@ -615,9 +618,9 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const setDifficulty = useCallback(
(difficulty: typeof state.difficulty) => {
console.log('[RoomMemoryPairsProvider] setDifficulty called:', difficulty)
console.log('[MatchingProvider] setDifficulty called:', difficulty)
const playerId = activePlayers[0] || ''
const playerId = (activePlayers[0] as string) || ''
sendMove({
type: 'SET_CONFIG',
playerId,
@@ -638,7 +641,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
},
}
console.log(
'[RoomMemoryPairsProvider] Saving difficulty to database:',
'[MatchingProvider] Saving difficulty to database:',
JSON.stringify(
{
roomId: roomData.id,
@@ -653,7 +656,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryPairsProvider] Cannot save difficulty - no roomData.id')
console.warn('[MatchingProvider] Cannot save difficulty - no roomData.id')
}
},
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
@@ -661,9 +664,9 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const setTurnTimer = useCallback(
(turnTimer: typeof state.turnTimer) => {
console.log('[RoomMemoryPairsProvider] setTurnTimer called:', turnTimer)
console.log('[MatchingProvider] setTurnTimer called:', turnTimer)
const playerId = activePlayers[0] || ''
const playerId = (activePlayers[0] as string) || ''
sendMove({
type: 'SET_CONFIG',
playerId,
@@ -684,7 +687,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
},
}
console.log(
'[RoomMemoryPairsProvider] Saving turnTimer to database:',
'[MatchingProvider] Saving turnTimer to database:',
JSON.stringify(
{
roomId: roomData.id,
@@ -699,7 +702,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
gameConfig: updatedConfig,
})
} else {
console.warn('[RoomMemoryPairsProvider] Cannot save turnTimer - no roomData.id')
console.warn('[MatchingProvider] Cannot save turnTimer - no roomData.id')
}
},
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
@@ -707,7 +710,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const goToSetup = useCallback(() => {
// Send GO_TO_SETUP move - synchronized across all room members
const playerId = activePlayers[0] || state.currentPlayer || ''
const playerId = (activePlayers[0] as string) || state.currentPlayer || ''
sendMove({
type: 'GO_TO_SETUP',
playerId,
@@ -719,11 +722,11 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const resumeGame = useCallback(() => {
// PAUSE/RESUME: Resume paused game if config unchanged
if (!canResumeGame) {
console.warn('[RoomMemoryPairs] Cannot resume - no paused game or config changed')
console.warn('[MatchingProvider] Cannot resume - no paused game or config changed')
return
}
const playerId = activePlayers[0] || state.currentPlayer || ''
const playerId = (activePlayers[0] as string) || state.currentPlayer || ''
sendMove({
type: 'RESUME_GAME',
playerId,
@@ -736,7 +739,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
(cardId: string | null) => {
// HOVER: Send hover state for networked presence
// Use current player as the one hovering
const playerId = state.currentPlayer || activePlayers[0] || ''
const playerId = state.currentPlayer || (activePlayers[0] as string) || ''
if (!playerId) return // No active player to send hover for
sendMove({
@@ -750,7 +753,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
)
// NO MORE effectiveState merging! Just use session state directly with gameMode added
const effectiveState = { ...state, gameMode } as MemoryPairsState & {
const effectiveState = { ...state, gameMode } as MatchingState & {
gameMode: GameMode
}
@@ -848,7 +851,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
)
}
const contextValue: MemoryPairsContextValue = {
const contextValue: MatchingContextValue = {
state: effectiveState,
dispatch: () => {
// No-op - replaced with sendMove
@@ -873,8 +876,14 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
activePlayers,
}
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
return <MatchingContext.Provider value={contextValue}>{children}</MatchingContext.Provider>
}
// Export the hook for this provider
export { useMemoryPairs } from './MemoryPairsContext'
export function useMatching() {
const context = useContext(MatchingContext)
if (!context) {
throw new Error('useMatching must be used within MatchingProvider')
}
return context
}

View File

@@ -0,0 +1,575 @@
/**
* Server-side validator for matching game
* Validates all game moves and state transitions
*/
import type {
GameCard,
MatchingConfig,
MatchingMove,
MatchingState,
Player,
} from './types'
import { generateGameCards } from './utils/cardGeneration'
import { canFlipCard, validateMatch } from './utils/matchValidation'
import type { GameValidator, ValidationResult } from '@/lib/arcade/validation/types'
export class MatchingGameValidator implements GameValidator<MatchingState, MatchingMove> {
validateMove(
state: MatchingState,
move: MatchingMove,
context?: { userId?: string; playerOwnership?: Record<string, string> }
): ValidationResult {
switch (move.type) {
case 'FLIP_CARD':
return this.validateFlipCard(state, move.data.cardId, move.playerId, context)
case 'START_GAME':
return this.validateStartGame(
state,
move.data.activePlayers,
move.data.cards,
move.data.playerMetadata
)
case 'CLEAR_MISMATCH':
return this.validateClearMismatch(state)
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
case 'RESUME_GAME':
return this.validateResumeGame(state)
case 'HOVER_CARD':
return this.validateHoverCard(state, move.data.cardId, move.playerId)
default:
return {
valid: false,
error: `Unknown move type: ${(move as any).type}`,
}
}
}
private validateFlipCard(
state: MatchingState,
cardId: string,
playerId: string,
context?: { userId?: string; playerOwnership?: Record<string, string> }
): ValidationResult {
// Game must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Cannot flip cards outside of playing phase',
}
}
// Check if it's the player's turn (in multiplayer)
if (state.activePlayers.length > 1 && state.currentPlayer !== playerId) {
console.log('[Validator] Turn check failed:', {
activePlayers: state.activePlayers,
currentPlayer: state.currentPlayer,
currentPlayerType: typeof state.currentPlayer,
playerId,
playerIdType: typeof playerId,
matches: state.currentPlayer === playerId,
})
return {
valid: false,
error: 'Not your turn',
}
}
// Check player ownership authorization (if context provided)
if (context?.userId && context?.playerOwnership) {
const playerOwner = context.playerOwnership[playerId]
if (playerOwner && playerOwner !== context.userId) {
console.log('[Validator] Player ownership check failed:', {
playerId,
playerOwner,
requestingUserId: context.userId,
})
return {
valid: false,
error: 'You can only move your own players',
}
}
}
// Find the card
const card = state.gameCards.find((c) => c.id === cardId)
if (!card) {
return {
valid: false,
error: 'Card not found',
}
}
// Validate using existing game logic
if (!canFlipCard(card, state.flippedCards, state.isProcessingMove)) {
return {
valid: false,
error: 'Cannot flip this card',
}
}
// Calculate new state
const newFlippedCards = [...state.flippedCards, card]
let newState = {
...state,
flippedCards: newFlippedCards,
isProcessingMove: newFlippedCards.length === 2,
// Clear mismatch feedback when player flips a new card
showMismatchFeedback: false,
}
// If two cards are flipped, check for match
if (newFlippedCards.length === 2) {
const [card1, card2] = newFlippedCards
const matchResult = validateMatch(card1, card2)
if (matchResult.isValid) {
// Match found - update cards
newState = {
...newState,
gameCards: newState.gameCards.map((c) =>
c.id === card1.id || c.id === card2.id
? { ...c, matched: true, matchedBy: state.currentPlayer }
: c
),
matchedPairs: state.matchedPairs + 1,
scores: {
...state.scores,
[state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1,
},
consecutiveMatches: {
...state.consecutiveMatches,
[state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1,
},
moves: state.moves + 1,
flippedCards: [],
isProcessingMove: false,
}
// Check if game is complete
if (newState.matchedPairs === newState.totalPairs) {
newState = {
...newState,
gamePhase: 'results',
gameEndTime: Date.now(),
}
}
} else {
// Match failed - keep cards flipped briefly so player can see them
// Client will handle clearing them after a delay
const shouldSwitchPlayer = state.activePlayers.length > 1
const nextPlayerIndex = shouldSwitchPlayer
? (state.activePlayers.indexOf(state.currentPlayer) + 1) % state.activePlayers.length
: 0
const nextPlayer = shouldSwitchPlayer
? state.activePlayers[nextPlayerIndex]
: state.currentPlayer
newState = {
...newState,
currentPlayer: nextPlayer,
consecutiveMatches: {
...state.consecutiveMatches,
[state.currentPlayer]: 0,
},
moves: state.moves + 1,
// Keep flippedCards so player can see both cards
flippedCards: newFlippedCards,
isProcessingMove: true, // Keep processing state so no more cards can be flipped
showMismatchFeedback: true,
// Clear hover state for the player whose turn is ending
playerHovers: {
...state.playerHovers,
[state.currentPlayer]: null,
},
}
}
}
return {
valid: true,
newState,
}
}
private validateStartGame(
state: MatchingState,
activePlayers: Player[],
cards?: GameCard[],
playerMetadata?: { [playerId: string]: any }
): ValidationResult {
// Allow starting a new game from any phase (for "New Game" button)
// Must have at least one player
if (!activePlayers || activePlayers.length === 0) {
return {
valid: false,
error: 'Must have at least one player',
}
}
// Use provided cards or generate new ones
const gameCards = cards || generateGameCards(state.gameType, state.difficulty)
const newState: MatchingState = {
...state,
gameCards,
cards: gameCards,
activePlayers,
playerMetadata: playerMetadata || {}, // Store player metadata for cross-user visibility
gamePhase: 'playing',
gameStartTime: Date.now(),
currentPlayer: activePlayers[0],
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
// PAUSE/RESUME: Save original config so we can detect changes
originalConfig: {
gameType: state.gameType,
difficulty: state.difficulty,
turnTimer: state.turnTimer,
},
// Clear any paused game state (starting fresh)
pausedGamePhase: undefined,
pausedGameState: undefined,
// Clear hover state when starting new game
playerHovers: {},
}
return {
valid: true,
newState,
}
}
private validateClearMismatch(state: MatchingState): ValidationResult {
// Only clear if there's actually a mismatch showing
// This prevents race conditions where CLEAR_MISMATCH arrives after cards have already been cleared
if (!state.showMismatchFeedback || state.flippedCards.length === 0) {
// Nothing to clear - return current state unchanged
return {
valid: true,
newState: state,
}
}
// Get the list of all non-current players whose hovers should be cleared
// (They're not playing this turn, so their hovers from previous turns should not show)
const clearedHovers = { ...state.playerHovers }
for (const playerId of state.activePlayers) {
// Clear hover for all players except the current player
// This ensures only the current player's active hover shows
if (playerId !== state.currentPlayer) {
clearedHovers[playerId] = null
}
}
// Clear mismatched cards and feedback
return {
valid: true,
newState: {
...state,
flippedCards: [],
showMismatchFeedback: false,
isProcessingMove: false,
// Clear hovers for non-current players when cards are cleared
playerHovers: clearedHovers,
},
}
}
/**
* STANDARD ARCADE PATTERN: GO_TO_SETUP
*
* Transitions the game back to setup phase, allowing players to reconfigure
* the game. This is synchronized across all room members.
*
* Can be called from any phase (setup, playing, results).
*
* PAUSE/RESUME: If called from 'playing' or 'results', saves game state
* to allow resuming later (if config unchanged).
*
* Pattern for all arcade games:
* - Validates the move is allowed
* - Sets gamePhase to 'setup'
* - Preserves current configuration (gameType, difficulty, etc.)
* - Saves game state for resume if coming from active game
* - Resets game progression state (scores, cards, etc.)
*/
private validateGoToSetup(state: MatchingState): ValidationResult {
// Determine if we're pausing an active game (for Resume functionality)
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
return {
valid: true,
newState: {
...state,
gamePhase: 'setup',
// Pause/Resume: Save game state if pausing from active game
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
pausedGameState: isPausingGame
? {
gameCards: state.gameCards,
currentPlayer: state.currentPlayer,
matchedPairs: state.matchedPairs,
moves: state.moves,
scores: state.scores,
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
consecutiveMatches: state.consecutiveMatches,
gameStartTime: state.gameStartTime,
}
: undefined,
// Keep originalConfig if it exists (was set when game started)
// This allows detecting if config changed while paused
// Reset visible game progression
gameCards: [],
cards: [],
flippedCards: [],
currentPlayer: '',
matchedPairs: 0,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
playerHovers: {}, // Clear hover state when returning to setup
// Preserve configuration - players can modify in setup
// gameType, difficulty, turnTimer stay as-is
},
}
}
/**
* STANDARD ARCADE PATTERN: SET_CONFIG
*
* Updates a configuration field during setup phase. This is synchronized
* across all room members in real-time, allowing collaborative setup.
*
* Pattern for all arcade games:
* - Only allowed during setup phase
* - Validates field name and value
* - Updates the configuration field
* - Other room members see the change immediately (optimistic + server validation)
*
* @param state Current game state
* @param field Configuration field name
* @param value New value for the field
*/
private validateSetConfig(
state: MatchingState,
field: 'gameType' | 'difficulty' | 'turnTimer',
value: any
): ValidationResult {
// Can only change config during setup phase
if (state.gamePhase !== 'setup') {
return {
valid: false,
error: 'Cannot change configuration outside of setup phase',
}
}
// Validate field-specific values
switch (field) {
case 'gameType':
if (value !== 'abacus-numeral' && value !== 'complement-pairs') {
return { valid: false, error: `Invalid gameType: ${value}` }
}
break
case 'difficulty':
if (![6, 8, 12, 15].includes(value)) {
return { valid: false, error: `Invalid difficulty: ${value}` }
}
break
case 'turnTimer':
if (typeof value !== 'number' || value < 5 || value > 300) {
return { valid: false, error: `Invalid turnTimer: ${value}` }
}
break
default:
return { valid: false, error: `Unknown config field: ${field}` }
}
// PAUSE/RESUME: If there's a paused game and config is changing,
// clear the paused game state (can't resume anymore)
const clearPausedGame = !!state.pausedGamePhase
// Apply the configuration change
return {
valid: true,
newState: {
...state,
[field]: value,
// Update totalPairs if difficulty changes
...(field === 'difficulty' ? { totalPairs: value } : {}),
// Clear paused game if config changed
...(clearPausedGame
? {
pausedGamePhase: undefined,
pausedGameState: undefined,
originalConfig: undefined,
}
: {}),
},
}
}
/**
* STANDARD ARCADE PATTERN: RESUME_GAME
*
* Resumes a paused game if configuration hasn't changed.
* Restores the saved game state from when GO_TO_SETUP was called.
*
* Pattern for all arcade games:
* - Validates there's a paused game
* - Validates config hasn't changed since pause
* - Restores game state and phase
* - Clears paused game state
*/
private validateResumeGame(state: MatchingState): ValidationResult {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return {
valid: false,
error: 'Can only resume from setup phase',
}
}
// Must have a paused game
if (!state.pausedGamePhase || !state.pausedGameState) {
return {
valid: false,
error: 'No paused game to resume',
}
}
// Config must match original (no changes while paused)
if (state.originalConfig) {
const configChanged =
state.gameType !== state.originalConfig.gameType ||
state.difficulty !== state.originalConfig.difficulty ||
state.turnTimer !== state.originalConfig.turnTimer
if (configChanged) {
return {
valid: false,
error: 'Cannot resume - configuration has changed',
}
}
}
// Restore the paused game
return {
valid: true,
newState: {
...state,
gamePhase: state.pausedGamePhase,
gameCards: state.pausedGameState.gameCards,
cards: state.pausedGameState.gameCards,
currentPlayer: state.pausedGameState.currentPlayer,
matchedPairs: state.pausedGameState.matchedPairs,
moves: state.pausedGameState.moves,
scores: state.pausedGameState.scores,
activePlayers: state.pausedGameState.activePlayers,
playerMetadata: state.pausedGameState.playerMetadata,
consecutiveMatches: state.pausedGameState.consecutiveMatches,
gameStartTime: state.pausedGameState.gameStartTime,
// Clear paused state
pausedGamePhase: undefined,
pausedGameState: undefined,
// Keep originalConfig for potential future pauses
},
}
}
/**
* Validate hover state update for networked presence
*
* Hover moves are lightweight and always valid - they just update
* which card a player is hovering over for UI feedback to other players.
*/
private validateHoverCard(
state: MatchingState,
cardId: string | null,
playerId: string
): ValidationResult {
// Hover is always valid - it's just UI state for networked presence
// Update the player's hover state
return {
valid: true,
newState: {
...state,
playerHovers: {
...state.playerHovers,
[playerId]: cardId,
},
},
}
}
isGameComplete(state: MatchingState): boolean {
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
}
getInitialState(config: MatchingConfig): MatchingState {
return {
cards: [],
gameCards: [],
flippedCards: [],
gameType: config.gameType,
difficulty: config.difficulty,
turnTimer: config.turnTimer,
gamePhase: 'setup',
currentPlayer: '',
matchedPairs: 0,
totalPairs: config.difficulty,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {}, // Initialize empty player metadata
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// PAUSE/RESUME: Initialize paused game fields
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
// HOVER: Initialize hover state
playerHovers: {},
}
}
}
// Singleton instance
export const matchingGameValidator = new MatchingGameValidator()

View File

@@ -2,8 +2,8 @@
import emojiData from 'emojibase-data/en/data.json'
import { useMemo, useState } from 'react'
import { css } from '../../../../../styled-system/css'
import { PLAYER_EMOJIS } from '../../../../constants/playerEmojis'
import { css } from '../../../../styled-system/css'
import { PLAYER_EMOJIS } from '@/constants/playerEmojis'
// Proper TypeScript interface for emojibase-data structure
interface EmojibaseEmoji {

View File

@@ -1,9 +1,9 @@
'use client'
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import type { GameCardProps } from '../context/types'
import { css } from '../../../../styled-system/css'
import { useGameMode } from '@/contexts/GameModeContext'
import type { GameCardProps } from '../types'
export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false }: GameCardProps) {
const appConfig = useAbacusConfig()

View File

@@ -3,13 +3,13 @@
import { useMemo } from 'react'
import { useViewerId } from '@/hooks/useViewerId'
import { MemoryGrid } from '@/components/matching/MemoryGrid'
import { css } from '../../../../../styled-system/css'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { css } from '../../../../styled-system/css'
import { useMatching } from '../Provider'
import { getGridConfiguration } from '../utils/cardGeneration'
import { GameCard } from './GameCard'
export function GamePhase() {
const { state, flipCard, hoverCard, gameMode } = useMemoryPairs()
const { state, flipCard, hoverCard, gameMode } = useMatching()
const { data: viewerId } = useViewerId()
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])

View File

@@ -3,17 +3,17 @@
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../../styled-system/css'
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
import { useFullscreen } from '../../../../contexts/FullscreenContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { css } from '../../../../styled-system/css'
import { StandardGameLayout } from '@/components/StandardGameLayout'
import { useFullscreen } from '@/contexts/FullscreenContext'
import { useMatching } from '../Provider'
import { GamePhase } from './GamePhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
export function MemoryPairsGame() {
const router = useRouter()
const { state, exitSession, resetGame, goToSetup } = useMemoryPairs()
const { state, exitSession, resetGame, goToSetup } = useMatching()
const { setFullscreenElement } = useFullscreen()
const gameRef = useRef<HTMLDivElement>(null)

View File

@@ -1,16 +1,16 @@
'use client'
import { useViewerId } from '@/hooks/useViewerId'
import { css } from '../../../../../styled-system/css'
import { gamePlurals } from '../../../../utils/pluralization'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { css } from '../../../../styled-system/css'
import { gamePlurals } from '@/utils/pluralization'
import { useMatching } from '../Provider'
interface PlayerStatusBarProps {
className?: string
}
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
const { state } = useMemoryPairs()
const { state } = useMatching()
const { data: viewerId } = useViewerId()
// Get active players from game state (not GameModeContext)

View File

@@ -1,14 +1,14 @@
'use client'
import { useRouter } from 'next/navigation'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { css } from '../../../../styled-system/css'
import { useGameMode } from '@/contexts/GameModeContext'
import { useMatching } from '../Provider'
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
export function ResultsPhase() {
const router = useRouter()
const { state, resetGame, activePlayers, gameMode, exitSession } = useMemoryPairs()
const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// Get active player data array

View File

@@ -1,9 +1,9 @@
'use client'
import { useState } from 'react'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { css } from '../../../../styled-system/css'
import { useGameMode } from '@/contexts/GameModeContext'
import { useMatching } from '../Provider'
// Add bounce animation for the start button
const bounceAnimation = `
@@ -39,7 +39,7 @@ export function SetupPhase() {
canResumeGame,
hasConfigChanged,
activePlayers: _activePlayers,
} = useMemoryPairs()
} = useMatching()
const { activePlayerCount, gameMode: _globalGameMode } = useGameMode()

View File

@@ -0,0 +1,11 @@
/**
* Matching Pairs Battle - Components
*/
export { MemoryPairsGame } from './MemoryPairsGame'
export { SetupPhase } from './SetupPhase'
export { GamePhase } from './GamePhase'
export { ResultsPhase } from './ResultsPhase'
export { GameCard } from './GameCard'
export { PlayerStatusBar } from './PlayerStatusBar'
export { EmojiPicker } from './EmojiPicker'

View File

@@ -0,0 +1,76 @@
/**
* Matching Pairs Battle Game Definition
*
* A turn-based multiplayer memory game where players flip cards to find matching pairs.
* Supports both abacus-numeral matching and complement pairs modes.
*/
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { MemoryPairsGame } from './components/MemoryPairsGame'
import { MatchingProvider } from './Provider'
import type { MatchingConfig, MatchingMove, MatchingState } from './types'
import { matchingGameValidator } from './Validator'
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. ' +
'Choose between abacus-numeral matching or complement pairs mode. Strategic thinking and quick memory are key to victory!',
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,
}
// Config validation function
function validateMatchingConfig(config: unknown): config is MatchingConfig {
if (typeof config !== 'object' || config === null) {
return false
}
const c = config as any
// Validate gameType
if (!('gameType' in c) || !['abacus-numeral', 'complement-pairs'].includes(c.gameType)) {
return false
}
// Validate difficulty (number of pairs)
if (!('difficulty' in c) || ![6, 8, 12, 15].includes(c.difficulty)) {
return false
}
// Validate turnTimer
if (
!('turnTimer' in c) ||
typeof c.turnTimer !== 'number' ||
c.turnTimer < 5 ||
c.turnTimer > 300
) {
return false
}
return true
}
export const matchingGame = defineGame<MatchingConfig, MatchingState, MatchingMove>({
manifest,
Provider: MatchingProvider,
GameComponent: MemoryPairsGame,
validator: matchingGameValidator,
defaultConfig,
validateConfig: validateMatchingConfig,
})

View File

@@ -0,0 +1,286 @@
/**
* Matching Pairs Battle - Type Definitions
*
* SDK-compatible types for the matching game.
*/
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk/types'
// ============================================================================
// Core Types
// ============================================================================
export type GameMode = 'single' | 'multiplayer'
export type GameType = 'abacus-numeral' | 'complement-pairs'
export type GamePhase = 'setup' | 'playing' | 'results'
export type CardType = 'abacus' | 'number' | 'complement'
export type Difficulty = 6 | 8 | 12 | 15 // Number of pairs
export type Player = string // Player ID (UUID)
export type TargetSum = 5 | 10 | 20
// ============================================================================
// Game Configuration (SDK-compatible)
// ============================================================================
/**
* Configuration for matching game
* Extends GameConfig for SDK compatibility
*/
export interface MatchingConfig extends GameConfig {
gameType: GameType
difficulty: Difficulty
turnTimer: number
}
// ============================================================================
// Game Entities
// ============================================================================
export interface GameCard {
id: string
type: CardType
number: number
complement?: number // For complement pairs
targetSum?: TargetSum // For complement pairs
matched: boolean
matchedBy?: Player // For two-player mode
element?: HTMLElement | null // For animations
}
export interface PlayerMetadata {
id: string // Player ID (UUID)
name: string
emoji: string
userId: string // Which user owns this player
color?: string
}
export interface PlayerScore {
[playerId: string]: number
}
export interface CelebrationAnimation {
id: string
type: 'match' | 'win' | 'confetti'
x: number
y: number
timestamp: number
}
export interface GameStatistics {
totalMoves: number
matchedPairs: number
totalPairs: number
gameTime: number
accuracy: number // Percentage of successful matches
averageTimePerMove: number
}
// ============================================================================
// Game State (SDK-compatible)
// ============================================================================
/**
* Main game state for matching pairs battle
* Extends GameState for SDK compatibility
*/
export interface MatchingState extends GameState {
// Core game data
cards: GameCard[]
gameCards: GameCard[]
flippedCards: GameCard[]
// Game configuration
gameType: GameType
difficulty: Difficulty
turnTimer: number // Seconds for turn timer
// Game progression
gamePhase: GamePhase
currentPlayer: Player
matchedPairs: number
totalPairs: number
moves: number
scores: PlayerScore
activePlayers: Player[] // Track active player IDs
playerMetadata: Record<string, PlayerMetadata> // Player metadata for cross-user visibility
consecutiveMatches: Record<string, number> // Track consecutive matches per player
// 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: Paused game state
originalConfig?: {
gameType: GameType
difficulty: Difficulty
turnTimer: number
}
pausedGamePhase?: GamePhase
pausedGameState?: {
gameCards: GameCard[]
currentPlayer: Player
matchedPairs: number
moves: number
scores: PlayerScore
activePlayers: Player[]
playerMetadata: Record<string, PlayerMetadata>
consecutiveMatches: Record<string, number>
gameStartTime: number | null
}
// HOVER: Networked hover state
playerHovers: Record<string, string | null> // playerId -> cardId (or null if not hovering)
}
// For backwards compatibility with existing code
export type MemoryPairsState = MatchingState
// ============================================================================
// Context Value
// ============================================================================
/**
* Context value for the matching game provider
* Exposes state and action creators to components
*/
export interface MatchingContextValue {
state: MatchingState & { gameMode: GameMode }
dispatch: React.Dispatch<any> // Deprecated - use action creators instead
// Computed values
isGameActive: boolean
canFlipCard: (cardId: string) => boolean
currentGameStatistics: GameStatistics
gameMode: GameMode
activePlayers: Player[]
// Pause/Resume
hasConfigChanged: boolean
canResumeGame: boolean
// Actions
startGame: () => void
flipCard: (cardId: string) => void
resetGame: () => void
setGameType: (type: GameType) => void
setDifficulty: (difficulty: Difficulty) => void
setTurnTimer: (timer: number) => void
goToSetup: () => void
resumeGame: () => void
hoverCard: (cardId: string | null) => void
exitSession: () => void
}
// ============================================================================
// Game Moves (SDK-compatible)
// ============================================================================
/**
* All possible moves in the matching game
* These match the move types validated by MatchingGameValidator
*/
export type MatchingMove =
| {
type: 'FLIP_CARD'
playerId: string
userId: string
timestamp: number
data: {
cardId: string
}
}
| {
type: 'START_GAME'
playerId: string
userId: string
timestamp: number
data: {
cards: GameCard[]
activePlayers: string[]
playerMetadata: Record<string, PlayerMetadata>
}
}
| {
type: 'CLEAR_MISMATCH'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'GO_TO_SETUP'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SET_CONFIG'
playerId: string
userId: string
timestamp: number
data: {
field: 'gameType' | 'difficulty' | 'turnTimer'
value: any
}
}
| {
type: 'RESUME_GAME'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'HOVER_CARD'
playerId: string
userId: string
timestamp: number
data: {
cardId: string | null
}
}
// ============================================================================
// Component Props
// ============================================================================
export interface GameCardProps {
card: GameCard
isFlipped: boolean
isMatched: boolean
onClick: () => void
disabled?: boolean
}
export interface PlayerIndicatorProps {
player: Player
isActive: boolean
score: number
name?: string
}
export interface GameGridProps {
cards: GameCard[]
onCardClick: (cardId: string) => void
disabled?: boolean
}
// ============================================================================
// Validation
// ============================================================================
export interface MatchValidationResult {
isValid: boolean
reason?: string
type: 'abacus-numeral' | 'complement' | 'invalid'
}

View File

@@ -1,4 +1,4 @@
import type { Difficulty, GameCard, GameType } from '../context/types'
import type { Difficulty, GameCard, GameType } from '../types'
// Utility function to generate unique random numbers
function generateUniqueNumbers(count: number, options: { min: number; max: number }): number[] {

View File

@@ -1,4 +1,4 @@
import type { GameStatistics, MemoryPairsState, Player } from '../context/types'
import type { GameStatistics, MemoryPairsState, Player } from '../types'
// Calculate final game score based on multiple factors
export function calculateFinalScore(

View File

@@ -1,4 +1,4 @@
import type { GameCard, MatchValidationResult } from '../context/types'
import type { GameCard, MatchValidationResult } from '../types'
// Validate abacus-numeral match (abacus card matches with number card of same value)
export function validateAbacusNumeralMatch(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,37 +7,8 @@ import { getAllGames } from '../lib/arcade/game-registry'
import { GameCard } from './GameCard'
// Game configuration defining player limits
// Note: "matching" (formerly "battle-arena") has been migrated to the modular game system
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 ⚔️',
maxPlayers: 4,
description: 'Multiplayer memory battle with friends',
longDescription:
'Battle friends in epic memory challenges. Match pairs faster than your opponents in this exciting multiplayer experience.',
url: '/arcade/matching',
icon: '⚔️',
chips: ['👥 Multiplayer', '🎯 Strategic', '🏆 Competitive'],
color: 'purple',
gradient: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)',
borderColor: 'purple.200',
difficulty: 'Intermediate',
},
'complement-race': {
name: 'Speed Complement Race',
fullName: 'Speed Complement Race 🏁',

View File

@@ -18,32 +18,29 @@ interface StandardGameLayoutProps {
export function StandardGameLayout({ children, className }: StandardGameLayoutProps) {
return (
<div
className={css(
{
// Exact viewport sizing - no scrolling ever
height: '100vh',
width: '100vw',
overflow: 'hidden',
className={`${css({
// Exact viewport sizing - no scrolling ever
height: '100vh',
width: '100vw',
overflow: 'hidden',
// Safe area for navigation (fixed at top: 4px, right: 4px)
// Navigation is ~60px tall, so we need padding-top of ~80px to be safe
paddingTop: '80px',
paddingRight: '4px', // Ensure nav doesn't overlap content on right side
paddingBottom: '4px',
paddingLeft: '4px',
// Safe area for navigation (fixed at top: 4px, right: 4px)
// Navigation is ~60px tall, so we need padding-top of ~80px to be safe
paddingTop: '80px',
paddingRight: '4px', // Ensure nav doesn't overlap content on right side
paddingBottom: '4px',
paddingLeft: '4px',
// Box sizing to include padding in dimensions
boxSizing: 'border-box',
// Box sizing to include padding in dimensions
boxSizing: 'border-box',
// Flex container for game content
display: 'flex',
flexDirection: 'column',
// Flex container for game content
display: 'flex',
flexDirection: 'column',
// Transparent background - themes will be applied at nav level
background: 'transparent',
},
className
)}
// Transparent background - themes will be applied at nav level
background: 'transparent',
})} ${className || ''}`}
>
{children}
</div>

View File

@@ -319,7 +319,7 @@ export function MemoryGrid<
.map(([playerId, cardId]) => {
const playerInfo = getPlayerHoverInfo(playerId)
// Get card element if player is hovering (cardId might be null)
const cardElement = cardId ? cardRefs.current.get(cardId) : null
const cardElement = cardId ? (cardRefs.current.get(cardId) ?? null) : null
// Check if it's this player's turn
const isPlayersTurn = state.currentPlayer === playerId
// Check if the card being hovered is flipped

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { EmojiPicker } from '../../app/games/matching/components/EmojiPicker'
import { EmojiPicker } from '@/arcade-games/matching/components/EmojiPicker'
import { useGameMode } from '../../contexts/GameModeContext'
import { generateUniquePlayerName } from '../../utils/playerNames'

View File

@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { MemoryPairsState } from '@/app/games/matching/context/types'
import type { MemoryPairsState } from '@/arcade-games/matching/types'
import { db, schema } from '@/db'
import {
applyGameMove,

View File

@@ -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)
}

View File

@@ -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, matching): Types inferred from game definitions
* - Legacy games (complement-race): Manual types until migrated
*
* These types are used across:
* - Database storage (room_game_configs table)
@@ -12,12 +12,11 @@
* - 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'
import type { matchingGame } from '@/arcade-games/matching'
/**
* Utility type: Extract config type from a game definition
@@ -41,30 +40,23 @@ 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>
/**
* Configuration for matching (memory pairs battle) game
* INFERRED from matchingGame.defaultConfig
*/
export type MatchingGameConfig = InferGameConfig<typeof matchingGame>
// ============================================================================
// Legacy Games (Manual Type Definitions)
// TODO: Migrate these games to the modular system for type inference
// ============================================================================
/**
* Configuration for matching (memory pairs) game
*/
export interface MatchingGameConfig {
gameType: GameType
difficulty: Difficulty
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 +75,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
matching: MatchingGameConfig
// Legacy games (manual types)
'complement-race': ComplementRaceGameConfig
}
/**

View File

@@ -108,6 +108,10 @@ 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'
import { matchingGame } from '@/arcade-games/matching'
registerGame(numberGuesserGame)
registerGame(mathSprintGame)
registerGame(memoryQuizGame)
registerGame(matchingGame)

View File

@@ -3,9 +3,9 @@
* Validates all game moves and state transitions
*/
import type { GameCard, MemoryPairsState, Player } from '@/app/games/matching/context/types'
import { generateGameCards } from '@/app/games/matching/utils/cardGeneration'
import { canFlipCard, validateMatch } from '@/app/games/matching/utils/matchValidation'
import type { GameCard, MemoryPairsState, Player } from '@/arcade-games/matching/types'
import { generateGameCards } from '@/arcade-games/matching/utils/cardGeneration'
import { canFlipCard, validateMatch } from '@/arcade-games/matching/utils/matchValidation'
import type { MatchingGameConfig } from '@/lib/arcade/game-configs'
import type { GameValidator, MatchingGameMove, ValidationResult } from './types'

View File

@@ -3,8 +3,8 @@
* Used on both client and server for arcade session validation
*/
import type { MemoryPairsState } from '@/app/games/matching/context/types'
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
import type { MemoryPairsState } from '@/arcade-games/matching/types'
import type { MemoryQuizState as SorobanQuizState } from '@/arcade-games/memory-quiz/types'
/**
* Game name type - auto-derived from validator registry

View File

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

View File

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