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>
This commit is contained in:
Thomas Hallock
2025-10-15 22:42:15 -05:00
parent d1c40f1733
commit 2a3af973f7
9 changed files with 1959 additions and 14 deletions

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

@@ -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,250 @@
/**
* 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
// ============================================================================
// 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

@@ -0,0 +1,194 @@
import type { Difficulty, GameCard, GameType } from '../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

@@ -0,0 +1,331 @@
import type { GameStatistics, MemoryPairsState, Player } from '../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

@@ -0,0 +1,222 @@
import type { GameCard, MatchValidationResult } from '../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

@@ -2,8 +2,8 @@
* Shared game configuration types
*
* ARCHITECTURE: Phase 3 - Type Inference
* - Modern games (number-guesser, math-sprint, memory-quiz): Types inferred from game definitions
* - Legacy games (matching, complement-race): Manual types until migrated
* - 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 { 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
@@ -47,20 +46,17 @@ export type MathSprintGameConfig = InferGameConfig<typeof mathSprintGame>
*/
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 complement-race game
* TODO: Define when implementing complement-race settings
@@ -83,9 +79,9 @@ export type GameConfigByName = {
'number-guesser': NumberGuesserGameConfig
'math-sprint': MathSprintGameConfig
'memory-quiz': MemoryQuizGameConfig
matching: MatchingGameConfig
// Legacy games (manual types)
matching: MatchingGameConfig
'complement-race': ComplementRaceGameConfig
}

View File

@@ -109,7 +109,9 @@ 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)