diff --git a/apps/web/docs/MATCHING_PAIRS_AUDIT.md b/apps/web/docs/MATCHING_PAIRS_AUDIT.md new file mode 100644 index 00000000..76fdc1ca --- /dev/null +++ b/apps/web/docs/MATCHING_PAIRS_AUDIT.md @@ -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 */} +
+

+ Performance Analysis +

+ + {analysis.strengths.length > 0 && ( +
+

+ ✅ Strengths: +

+ +
+ )} + + {analysis.improvements.length > 0 && ( +
+

+ 💡 Areas for Improvement: +

+ +
+ )} +
+``` + +**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. diff --git a/apps/web/src/arcade-games/matching/Validator.ts b/apps/web/src/arcade-games/matching/Validator.ts new file mode 100644 index 00000000..d0151189 --- /dev/null +++ b/apps/web/src/arcade-games/matching/Validator.ts @@ -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 { + validateMove( + state: MatchingState, + move: MatchingMove, + context?: { userId?: string; playerOwnership?: Record } + ): 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 } + ): 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() diff --git a/apps/web/src/arcade-games/matching/index.ts b/apps/web/src/arcade-games/matching/index.ts new file mode 100644 index 00000000..e52c2c66 --- /dev/null +++ b/apps/web/src/arcade-games/matching/index.ts @@ -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({ + manifest, + Provider: MatchingProvider, + GameComponent: MemoryPairsGame, + validator: matchingGameValidator, + defaultConfig, + validateConfig: validateMatchingConfig, +}) diff --git a/apps/web/src/arcade-games/matching/types.ts b/apps/web/src/arcade-games/matching/types.ts new file mode 100644 index 00000000..0b3da07f --- /dev/null +++ b/apps/web/src/arcade-games/matching/types.ts @@ -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 // Player metadata for cross-user visibility + consecutiveMatches: Record // 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 + consecutiveMatches: Record + gameStartTime: number | null + } + + // HOVER: Networked hover state + playerHovers: Record // 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 + } + } + | { + type: 'CLEAR_MISMATCH' + playerId: string + userId: string + timestamp: number + data: Record + } + | { + type: 'GO_TO_SETUP' + playerId: string + userId: string + timestamp: number + data: Record + } + | { + 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 + } + | { + 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' +} diff --git a/apps/web/src/arcade-games/matching/utils/cardGeneration.ts b/apps/web/src/arcade-games/matching/utils/cardGeneration.ts new file mode 100644 index 00000000..dd0b4ebe --- /dev/null +++ b/apps/web/src/arcade-games/matching/utils/cardGeneration.ts @@ -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() + 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(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 = { + 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)}` +} diff --git a/apps/web/src/arcade-games/matching/utils/gameScoring.ts b/apps/web/src/arcade-games/matching/utils/gameScoring.ts new file mode 100644 index 00000000..ea398f64 --- /dev/null +++ b/apps/web/src/arcade-games/matching/utils/gameScoring.ts @@ -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, + } +} diff --git a/apps/web/src/arcade-games/matching/utils/matchValidation.ts b/apps/web/src/arcade-games/matching/utils/matchValidation.ts new file mode 100644 index 00000000..f706b490 --- /dev/null +++ b/apps/web/src/arcade-games/matching/utils/matchValidation.ts @@ -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, + } +} diff --git a/apps/web/src/lib/arcade/game-configs.ts b/apps/web/src/lib/arcade/game-configs.ts index 64290817..96436a48 100644 --- a/apps/web/src/lib/arcade/game-configs.ts +++ b/apps/web/src/lib/arcade/game-configs.ts @@ -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 */ export type MemoryQuizGameConfig = InferGameConfig +/** + * Configuration for matching (memory pairs battle) game + * INFERRED from matchingGame.defaultConfig + */ +export type MatchingGameConfig = InferGameConfig + // ============================================================================ // 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 } diff --git a/apps/web/src/lib/arcade/game-registry.ts b/apps/web/src/lib/arcade/game-registry.ts index 4561eb5e..c7238fe1 100644 --- a/apps/web/src/lib/arcade/game-registry.ts +++ b/apps/web/src/lib/arcade/game-registry.ts @@ -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)