diff --git a/apps/web/.claude/GAME_STATS_COMPARISON.md b/apps/web/.claude/GAME_STATS_COMPARISON.md new file mode 100644 index 00000000..714d5e96 --- /dev/null +++ b/apps/web/.claude/GAME_STATS_COMPARISON.md @@ -0,0 +1,584 @@ +# Cross-Game Stats Analysis & Universal Data Model + +## Overview + +This document analyzes ALL arcade games to ensure our `GameResult` type works universally. + +## Games Analyzed + +1. ✅ **Matching** (Memory Pairs) +2. ✅ **Complement Race** (Math race game) +3. ✅ **Memory Quiz** (Number memory game) +4. ✅ **Card Sorting** (Sort abacus cards) +5. ✅ **Rithmomachia** (Strategic board game) +6. 🔍 **YJS Demo** (Multiplayer demo - skipping for now) + +--- + +## Per-Game Analysis + +### 1. Matching (Memory Pairs) + +**Game Type**: Memory/Pattern Matching +**Players**: 1-N (competitive multiplayer) +**How to Win**: Most pairs matched (multiplayer) OR complete all pairs (solo) + +**Data Tracked**: +```typescript +{ + scores: { [playerId]: matchCount } + moves: number + matchedPairs: number + totalPairs: number + gameTime: milliseconds + accuracy: percentage (matchedPairs / moves * 100) + grade: 'A+' | 'A' | 'B+' | ... + starRating: 1-5 +} +``` + +**Winner Determination**: +- Solo: completed = won +- Multiplayer: highest score wins + +**Fits GameResult?** ✅ +```typescript +{ + gameType: 'matching', + duration: gameTime, + playerResults: [{ + playerId, + won: isWinner, + score: matchCount, + accuracy: 0.0-1.0, + metrics: { moves, matchedPairs, difficulty } + }] +} +``` + +--- + +### 2. Complement Race + +**Game Type**: Racing/Quiz hybrid +**Players**: 1-N (competitive race) +**How to Win**: Highest score OR reach finish line first (depending on mode) + +**Data Tracked**: +```typescript +{ + players: { + [playerId]: { + score: number + streak: number + bestStreak: number + correctAnswers: number + totalQuestions: number + position: 0-100% (for practice/survival) + deliveredPassengers: number (sprint mode) + } + } + gameTime: milliseconds + winner: playerId | null + leaderboard: [{ playerId, score, rank }] +} +``` + +**Winner Determination**: +- Practice/Survival: reach 100% position +- Sprint: highest score (delivered passengers) + +**Fits GameResult?** ✅ +```typescript +{ + gameType: 'complement-race', + duration: gameTime, + playerResults: [{ + playerId, + won: winnerId === playerId, + score: player.score, + accuracy: player.correctAnswers / player.totalQuestions, + placement: leaderboard rank, + metrics: { + streak: player.bestStreak, + correctAnswers: player.correctAnswers, + totalQuestions: player.totalQuestions + } + }] +} +``` + +--- + +### 3. Memory Quiz + +**Game Type**: Memory/Recall +**Players**: 1-N (cooperative OR competitive) +**How to Win**: +- Cooperative: team finds all numbers +- Competitive: most correct answers + +**Data Tracked**: +```typescript +{ + playerScores: { + [playerId]: { correct: number, incorrect: number } + } + foundNumbers: number[] + correctAnswers: number[] + selectedCount: 2 | 5 | 8 | 12 | 15 + playMode: 'cooperative' | 'competitive' + gameTime: milliseconds +} +``` + +**Winner Determination**: +- Cooperative: ALL found = team wins +- Competitive: highest correct count wins + +**Fits GameResult?** ✅ **BUT needs special handling for cooperative** +```typescript +{ + gameType: 'memory-quiz', + duration: gameTime, + playerResults: [{ + playerId, + won: playMode === 'cooperative' + ? foundAll // All players win or lose together + : hasHighestScore, // Individual winner + score: playerScores[playerId].correct, + accuracy: correct / (correct + incorrect), + metrics: { + correct: playerScores[playerId].correct, + incorrect: playerScores[playerId].incorrect, + difficulty: selectedCount + } + }], + metadata: { + playMode: 'cooperative' | 'competitive', + isTeamVictory: boolean // ← IMPORTANT for cooperative games + } +} +``` + +**NEW INSIGHT**: Cooperative games need special handling - all players share win/loss! + +--- + +### 4. Card Sorting + +**Game Type**: Sorting/Puzzle +**Players**: 1-N (solo, collaborative, competitive, relay) +**How to Win**: +- Solo: achieve high score (0-100) +- Collaborative: team achieves score +- Competitive: highest individual score +- Relay: TBD (not fully implemented) + +**Data Tracked**: +```typescript +{ + scoreBreakdown: { + finalScore: 0-100 + exactMatches: number + lcsLength: number // Longest common subsequence + inversions: number // Out-of-order pairs + relativeOrderScore: 0-100 + exactPositionScore: 0-100 + inversionScore: 0-100 + elapsedTime: seconds + } + gameMode: 'solo' | 'collaborative' | 'competitive' | 'relay' +} +``` + +**Winner Determination**: +- Solo/Collaborative: score > threshold (e.g., 70+) +- Competitive: highest score + +**Fits GameResult?** ✅ **Similar to Memory Quiz** +```typescript +{ + gameType: 'card-sorting', + duration: elapsedTime * 1000, + playerResults: [{ + playerId, + won: gameMode === 'collaborative' + ? scoreBreakdown.finalScore >= 70 // Team threshold + : hasHighestScore, + score: scoreBreakdown.finalScore, + accuracy: scoreBreakdown.exactMatches / cardCount, + metrics: { + exactMatches: scoreBreakdown.exactMatches, + inversions: scoreBreakdown.inversions, + lcsLength: scoreBreakdown.lcsLength + } + }], + metadata: { + gameMode, + isTeamVictory: gameMode === 'collaborative' + } +} +``` + +--- + +### 5. Rithmomachia + +**Game Type**: Strategic board game (2-player only) +**Players**: Exactly 2 (White vs Black) +**How to Win**: Multiple victory conditions (harmony, points, exhaustion, resignation) + +**Data Tracked**: +```typescript +{ + winner: 'W' | 'B' | null + winCondition: 'HARMONY' | 'EXHAUSTION' | 'RESIGNATION' | 'POINTS' | ... + capturedPieces: { W: Piece[], B: Piece[] } + pointsCaptured: { W: number, B: number } + history: MoveRecord[] + gameTime: milliseconds (computed from history) +} +``` + +**Winner Determination**: +- Specific win condition triggered +- No draws (or rare) + +**Fits GameResult?** ✅ **Needs win condition metadata** +```typescript +{ + gameType: 'rithmomachia', + duration: gameTime, + playerResults: [ + { + playerId: whitePlayerId, + won: winner === 'W', + score: capturedPieces.W.length, // or pointsCaptured.W + metrics: { + capturedPieces: capturedPieces.W.length, + points: pointsCaptured?.W || 0, + moves: history.filter(m => m.color === 'W').length + } + }, + { + playerId: blackPlayerId, + won: winner === 'B', + score: capturedPieces.B.length, + metrics: { + capturedPieces: capturedPieces.B.length, + points: pointsCaptured?.B || 0, + moves: history.filter(m => m.color === 'B').length + } + } + ], + metadata: { + winCondition: 'HARMONY' | 'POINTS' | ... + } +} +``` + +--- + +## Cross-Game Patterns Identified + +### Pattern 1: Competitive (Most Common) +**Games**: Matching (multiplayer), Complement Race, Memory Quiz (competitive), Card Sorting (competitive) + +**Characteristics**: +- Each player has their own score +- Winner = highest score +- Players track individually + +**Stats to track per player**: +- games_played ++ +- wins ++ (if winner) +- losses ++ (if not winner) +- best_time (if faster) +- highest_accuracy (if better) + +--- + +### Pattern 2: Cooperative (Team-Based) +**Games**: Memory Quiz (cooperative), Card Sorting (collaborative) + +**Characteristics**: +- All players share outcome +- Team wins or loses together +- Individual contributions still tracked + +**Stats to track per player**: +- games_played ++ +- wins ++ (if TEAM won) ← Key difference +- losses ++ (if TEAM lost) +- Individual metrics still tracked (correct answers, etc.) + +**CRITICAL**: Check `metadata.isTeamVictory` to determine if all players get same win/loss! + +--- + +### Pattern 3: Head-to-Head (Exactly 2 Players) +**Games**: Rithmomachia + +**Characteristics**: +- Always 2 players +- One wins, one loses (rare draws) +- Different win conditions + +**Stats to track per player**: +- games_played ++ +- wins ++ (winner only) +- losses ++ (loser only) +- Game-specific metrics (captures, harmonies) + +--- + +### Pattern 4: Solo Completion +**Games**: Matching (solo), Complement Race (practice), Memory Quiz (solo), Card Sorting (solo) + +**Characteristics**: +- Single player +- Win = completion or threshold +- Compete against self/time + +**Stats to track**: +- games_played ++ +- wins ++ (if completed/threshold met) +- losses ++ (if failed/gave up) +- best_time, highest_accuracy + +--- + +## Refined Universal Data Model + +### GameResult Type (UPDATED) + +```typescript +export interface GameResult { + // Game identification + gameType: string // e.g., "matching", "complement-race", etc. + + // Player results (supports 1-N players) + playerResults: PlayerGameResult[] + + // Timing + completedAt: number // timestamp + duration: number // milliseconds + + // Optional game-specific data + metadata?: { + // For cooperative games + isTeamVictory?: boolean // ← NEW: all players share win/loss + + // For specific win conditions + winCondition?: string // e.g., "HARMONY", "POINTS", "TIMEOUT" + + // For game modes + gameMode?: string // e.g., "solo", "competitive", "cooperative" + + // Any other game-specific info + [key: string]: unknown + } +} + +export interface PlayerGameResult { + playerId: string + + // Outcome + won: boolean // For cooperative: all players same value + placement?: number // 1st, 2nd, 3rd (for competitive with >2 players) + + // Performance + score?: number + accuracy?: number // 0.0 - 1.0 + completionTime?: number // milliseconds (player-specific time) + + // Game-specific metrics (optional, stored as JSON in DB) + metrics?: { + // Matching + moves?: number + matchedPairs?: number + difficulty?: number + + // Complement Race + streak?: number + correctAnswers?: number + totalQuestions?: number + + // Memory Quiz + correct?: number + incorrect?: number + + // Card Sorting + exactMatches?: number + inversions?: number + lcsLength?: number + + // Rithmomachia + capturedPieces?: number + points?: number + + // Extensible for future games + [key: string]: unknown + } +} +``` + +--- + +## Stats Recording Logic (UPDATED) + +### For Each Player in GameResult + +```typescript +// Fetch player stats +const stats = await getPlayerStats(playerId) + +// Always increment +stats.gamesPlayed++ + +// Handle wins/losses based on game type +if (gameResult.metadata?.isTeamVictory !== undefined) { + // COOPERATIVE: All players share outcome + if (playerResult.won) { + stats.totalWins++ + } else { + stats.totalLosses++ + } +} else { + // COMPETITIVE/SOLO: Individual outcome + if (playerResult.won) { + stats.totalWins++ + } else { + stats.totalLosses++ + } +} + +// Update performance metrics +if (playerResult.completionTime && ( + !stats.bestTime || playerResult.completionTime < stats.bestTime +)) { + stats.bestTime = playerResult.completionTime +} + +if (playerResult.accuracy && playerResult.accuracy > stats.highestAccuracy) { + stats.highestAccuracy = playerResult.accuracy +} + +// Update per-game stats (JSON) +stats.gameStats[gameResult.gameType] = { + gamesPlayed: (stats.gameStats[gameResult.gameType]?.gamesPlayed || 0) + 1, + wins: (stats.gameStats[gameResult.gameType]?.wins || 0) + (playerResult.won ? 1 : 0), + // ... other game-specific aggregates +} + +// Update favorite game type (most played) +stats.favoriteGameType = getMostPlayedGame(stats.gameStats) + +// Update timestamps +stats.lastPlayedAt = gameResult.completedAt +stats.updatedAt = Date.now() +``` + +--- + +## Database Schema (CONFIRMED) + +No changes needed from original design! The `metrics` JSON field handles game-specific data perfectly. + +```sql +CREATE TABLE player_stats ( + player_id TEXT PRIMARY KEY, + + -- Aggregates + games_played INTEGER NOT NULL DEFAULT 0, + total_wins INTEGER NOT NULL DEFAULT 0, + total_losses INTEGER NOT NULL DEFAULT 0, + + -- Performance + best_time INTEGER, + highest_accuracy REAL NOT NULL DEFAULT 0, + + -- Per-game breakdown (JSON) + game_stats TEXT NOT NULL DEFAULT '{}', + + -- Meta + favorite_game_type TEXT, + last_played_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +``` + +--- + +## Key Insights & Design Decisions + +### 1. Cooperative Games Need Special Flag +**Problem**: Memory Quiz (cooperative) and Card Sorting (collaborative) - all players share win/loss. + +**Solution**: Add `metadata.isTeamVictory: boolean` to `GameResult`. When `true`, recording logic gives ALL players the same win/loss. + +### 2. Flexible Metrics Field +**Problem**: Each game tracks different metrics (moves, streak, inversions, etc.). + +**Solution**: `PlayerGameResult.metrics` is an open object. Store game-specific data here, saved as JSON in DB. + +### 3. Placement for Tournaments +**Problem**: 3+ player games need to track ranking (1st, 2nd, 3rd). + +**Solution**: `PlayerGameResult.placement` field. Useful for leaderboards. + +### 4. Win Conditions Matter +**Problem**: Rithmomachia has multiple win conditions (harmony, points, etc.). + +**Solution**: `metadata.winCondition` stores how the game was won. Useful for achievements/stats breakdown. + +### 5. Score is Optional +**Problem**: Not all games have scores (e.g., Rithmomachia can win by harmony without points enabled). + +**Solution**: Make `score` optional. Use `won` as primary outcome indicator. + +--- + +## Testing Matrix + +### Scenarios to Test + +| Game | Mode | Players | Expected Outcome | +|------|------|---------|------------------| +| Matching | Solo | 1 | Player wins if completed | +| Matching | Competitive | 2+ | Winner = highest score, others lose | +| Complement Race | Sprint | 2+ | Winner = highest score | +| Memory Quiz | Cooperative | 2+ | ALL win or ALL lose (team) | +| Memory Quiz | Competitive | 2+ | Winner = most correct | +| Card Sorting | Solo | 1 | Win if score >= 70 | +| Card Sorting | Collaborative | 2+ | ALL win or ALL lose (team) | +| Card Sorting | Competitive | 2+ | Winner = highest score | +| Rithmomachia | PvP | 2 | One wins (by condition), one loses | + +--- + +## Conclusion + +✅ **Universal `GameResult` type CONFIRMED to work for all games** + +**Key Requirements**: +1. Support 1-N players (flexible array) +2. Support cooperative games (isTeamVictory flag) +3. Support game-specific metrics (open metrics object) +4. Support multiple win conditions (winCondition metadata) +5. Track both individual AND team performance + +**Next Steps**: +1. Update `.claude/PER_PLAYER_STATS_ARCHITECTURE.md` with refined types +2. Implement database schema +3. Build API endpoints +4. Create React hooks +5. Integrate with each game (starting with Matching) + +--- + +**Status**: ✅ Complete cross-game analysis +**Result**: GameResult type is universal and robust +**Date**: 2025-01-03 diff --git a/apps/web/.claude/MATCHING_GAME_STATS_INTEGRATION.md b/apps/web/.claude/MATCHING_GAME_STATS_INTEGRATION.md new file mode 100644 index 00000000..16e84d38 --- /dev/null +++ b/apps/web/.claude/MATCHING_GAME_STATS_INTEGRATION.md @@ -0,0 +1,283 @@ +# Matching Game Stats Integration Guide + +## Quick Reference + +**Files to modify**: `src/arcade-games/matching/components/ResultsPhase.tsx` + +**What we're adding**: Call `useRecordGameResult()` when game completes to save per-player stats. + +## Current State Analysis + +### ResultsPhase.tsx (lines 9-29) + +Already has all the data we need: + +```typescript +const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching() +const { players: playerMap, activePlayers: activePlayerIds } = useGameMode() + +const gameTime = state.gameEndTime && state.gameStartTime + ? state.gameEndTime - state.gameStartTime + : 0 + +const analysis = getPerformanceAnalysis(state) +const multiplayerResult = gameMode === 'multiplayer' + ? getMultiplayerWinner(state, activePlayers) + : null +``` + +**Available data:** +- ✅ `state.scores` - scores by player ID +- ✅ `state.gameStartTime`, `state.gameEndTime` - timing +- ✅ `state.matchedPairs`, `state.totalPairs` - completion +- ✅ `state.moves` - total moves +- ✅ `activePlayers` - array of player IDs +- ✅ `multiplayerResult.winners` - who won +- ✅ `analysis.statistics.accuracy` - accuracy percentage + +## Implementation Steps + +### Step 1: Add state flag to prevent duplicate recording + +Add `recorded: boolean` to `MatchingState` type: + +```typescript +// src/arcade-games/matching/types.ts (add to MatchingState interface) + +export interface MatchingState extends GameState { + // ... existing fields ... + + // Stats recording + recorded?: boolean // ← ADD THIS +} +``` + +### Step 2: Import the hook in ResultsPhase.tsx + +```typescript +// At top of src/arcade-games/matching/components/ResultsPhase.tsx + +import { useEffect } from 'react' // ← ADD if not present +import { useRecordGameResult } from '@/hooks/useRecordGameResult' +import type { GameResult } from '@/lib/arcade/stats/types' +``` + +### Step 3: Call the hook + +```typescript +// Inside ResultsPhase component, after existing hooks + +export function ResultsPhase() { + const router = useRouter() + const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching() + const { players: playerMap, activePlayers: activePlayerIds } = useGameMode() + + // ← ADD THIS + const { mutate: recordGame, isPending: isRecording } = useRecordGameResult() + + // ... existing code ... +``` + +### Step 4: Record game result on mount + +Add this useEffect after the hook declarations: + +```typescript +// Record game result once when entering results phase +useEffect(() => { + // Only record if we haven't already + if (state.phase === 'results' && !state.recorded && !isRecording) { + const gameTime = state.gameEndTime && state.gameStartTime + ? state.gameEndTime - state.gameStartTime + : 0 + + const analysis = getPerformanceAnalysis(state) + const multiplayerResult = gameMode === 'multiplayer' + ? getMultiplayerWinner(state, activePlayers) + : null + + // Build GameResult + const gameResult: GameResult = { + gameType: state.gameType === 'abacus-numeral' + ? 'matching-abacus' + : 'matching-complements', + completedAt: state.gameEndTime || Date.now(), + duration: gameTime, + + playerResults: activePlayers.map(playerId => { + const score = state.scores[playerId] || 0 + const won = multiplayerResult + ? multiplayerResult.winners.includes(playerId) + : state.matchedPairs === state.totalPairs // Solo = completed + + // In multiplayer, calculate per-player accuracy from their score + // In single player, use overall accuracy + const playerAccuracy = gameMode === 'multiplayer' + ? score / state.totalPairs // Their score as fraction of total pairs + : analysis.statistics.accuracy / 100 // Convert percentage to 0-1 + + return { + playerId, + won, + score, + accuracy: playerAccuracy, + completionTime: gameTime, + metrics: { + moves: state.moves, + matchedPairs: state.matchedPairs, + difficulty: state.difficulty, + } + } + }), + + metadata: { + gameType: state.gameType, + difficulty: state.difficulty, + grade: analysis.grade, + starRating: analysis.starRating, + } + } + + // Record to database + recordGame(gameResult, { + onSuccess: (updates) => { + console.log('✅ Stats recorded:', updates) + // Mark as recorded to prevent duplicate saves + // Note: This assumes Provider has a way to update state.recorded + // We'll need to add an action for this + }, + onError: (error) => { + console.error('❌ Failed to record stats:', error) + } + }) + } +}, [state.phase, state.recorded, isRecording, /* ... deps */]) +``` + +### Step 5: Add loading state UI (optional) + +Show a subtle loading indicator while recording: + +```typescript +// At the top of the return statement in ResultsPhase + +if (isRecording) { + return ( +
+

Saving results...

+
+ ) +} +``` + +Or keep it subtle and just disable buttons: + +```typescript +// On the "Play Again" button + +``` + +## Provider Changes Needed + +The Provider needs an action to mark the game as recorded: + +```typescript +// src/arcade-games/matching/Provider.tsx + +// Add to the context type +export interface MatchingContextType { + // ... existing ... + markAsRecorded: () => void // ← ADD THIS +} + +// Add to the reducer or state update logic +const markAsRecorded = useCallback(() => { + setState(prev => ({ ...prev, recorded: true })) +}, []) + +// Add to the context value +const contextValue: MatchingContextType = { + // ... existing ... + markAsRecorded, +} +``` + +Then in ResultsPhase useEffect: + +```typescript +onSuccess: (updates) => { + console.log('✅ Stats recorded:', updates) + markAsRecorded() // ← Use this instead +} +``` + +## Testing Checklist + +### Solo Game +- [ ] Play a game to completion +- [ ] Check console for "✅ Stats recorded" +- [ ] Refresh page +- [ ] Go to `/games` page +- [ ] Verify player's gamesPlayed incremented +- [ ] Verify player's totalWins incremented (if completed) + +### Multiplayer Game +- [ ] Activate 2+ players +- [ ] Play a game to completion +- [ ] Check console for stats for ALL players +- [ ] Go to `/games` page +- [ ] Verify each player's stats updated independently +- [ ] Winner should have +1 win +- [ ] All players should have +1 games played + +### Edge Cases +- [ ] Incomplete game (exit early) - should NOT record +- [ ] Play again from results - should NOT duplicate record +- [ ] Network error during save - should show error, not mark as recorded + +## Common Issues + +### Issue: Stats recorded multiple times +**Cause**: useEffect dependency array missing or incorrect +**Fix**: Ensure `state.recorded` is in deps and checked in condition + +### Issue: Can't read property 'id' of undefined +**Cause**: Player not found in playerMap +**Fix**: Add null checks when mapping activePlayers + +### Issue: Accuracy is always 100% or 0% +**Cause**: Wrong calculation or unit (percentage vs decimal) +**Fix**: Ensure accuracy is 0.0 - 1.0, not 0-100 + +### Issue: Single player never "wins" +**Cause**: Wrong win condition for solo mode +**Fix**: Solo player wins if they complete all pairs (`state.matchedPairs === state.totalPairs`) + +## Next Steps After Integration + +1. ✅ Verify stats save correctly +2. ✅ Update `/games` page to fetch and display per-player stats +3. ✅ Test with different game modes and difficulties +4. 🔄 Repeat this pattern for other arcade games +5. 📊 Add stats visualization/charts (future) + +--- + +**Status**: Ready for implementation +**Blocked by**: +- Database schema (player_stats table) +- API endpoints (/api/player-stats/record-game) +- React hooks (useRecordGameResult) diff --git a/apps/web/.claude/PER_PLAYER_STATS_ARCHITECTURE.md b/apps/web/.claude/PER_PLAYER_STATS_ARCHITECTURE.md new file mode 100644 index 00000000..0d819782 --- /dev/null +++ b/apps/web/.claude/PER_PLAYER_STATS_ARCHITECTURE.md @@ -0,0 +1,594 @@ +# Per-Player Stats Architecture & Implementation Plan + +## Executive Summary + +This document outlines the architecture for tracking game statistics per-player (not per-user). Each local player profile will maintain their own game history, wins, losses, and performance metrics. We'll build a universal framework that any arcade game can use to record results. + +**Starting point**: Matching/Memory Lightning game + +## Current State Problems + +1. ❌ Global `user_stats` table exists but games never update it +2. ❌ `/games` page shows same global stats for all players +3. ❌ No framework for games to save results +4. ❌ Players table has no stats fields + +## Architecture Design + +### 1. Database Schema + +#### New Table: `player_stats` + +```sql +CREATE TABLE player_stats ( + player_id TEXT PRIMARY KEY REFERENCES players(id) ON DELETE CASCADE, + + -- Aggregate stats + games_played INTEGER NOT NULL DEFAULT 0, + total_wins INTEGER NOT NULL DEFAULT 0, + total_losses INTEGER NOT NULL DEFAULT 0, + + -- Performance metrics + best_time INTEGER, -- Best completion time (ms) + highest_accuracy REAL NOT NULL DEFAULT 0, -- 0.0 - 1.0 + + -- Game preferences + favorite_game_type TEXT, -- Most played game + + -- Per-game stats (JSON) + game_stats TEXT NOT NULL DEFAULT '{}', -- { "matching": { wins: 5, played: 10 }, ... } + + -- Timestamps + last_played_at INTEGER, -- timestamp + created_at INTEGER NOT NULL, -- timestamp + updated_at INTEGER NOT NULL -- timestamp +); + +CREATE INDEX player_stats_last_played_idx ON player_stats(last_played_at); +``` + +#### Per-Game Stats Structure (JSON) + +```typescript +type PerGameStats = { + [gameName: string]: { + gamesPlayed: number + wins: number + losses: number + bestTime: number | null + highestAccuracy: number + averageScore: number + lastPlayed: number // timestamp + } +} +``` + +#### Keep `user_stats`? + +**Decision**: Deprecate `user_stats` table. All stats are now per-player. + +**Reasoning**: +- Users can have multiple players +- Aggregate "user level" stats can be computed by summing player stats +- Simpler mental model: players compete, players have stats +- `/games` page displays players, so showing player stats makes sense + +### 2. Universal Game Result Types + +**Analysis**: Examined 5 arcade games (Matching, Complement Race, Memory Quiz, Card Sorting, Rithmomachia) +**Key Finding**: Cooperative games need special handling - all players share win/loss! +**See**: `.claude/GAME_STATS_COMPARISON.md` for detailed cross-game analysis + +```typescript +// src/lib/arcade/stats/types.ts + +/** + * Standard game result that all arcade games must provide + * + * Supports: + * - 1-N players + * - Competitive (individual winners) + * - Cooperative (team wins/losses) + * - Solo completion + * - Head-to-head (2-player) + */ +export interface GameResult { + // Game identification + gameType: string // e.g., "matching", "complement-race", "memory-quiz" + + // Player results (for multiplayer, array of results) + playerResults: PlayerGameResult[] + + // Game metadata + completedAt: number // timestamp + duration: number // milliseconds + + // Optional game-specific data + metadata?: { + // For cooperative games (Memory Quiz, Card Sorting collaborative) + isTeamVictory?: boolean // All players share win/loss + + // For specific win conditions (Rithmomachia) + winCondition?: string // e.g., "HARMONY", "POINTS", "TIMEOUT" + + // For game modes + gameMode?: string // e.g., "solo", "competitive", "cooperative" + + // Extensible for other game-specific info + [key: string]: unknown + } +} + +export interface PlayerGameResult { + playerId: string + + // Outcome + won: boolean // For cooperative: all players have same value + placement?: number // 1st, 2nd, 3rd place (for tournaments with 3+ players) + + // Performance + score?: number + accuracy?: number // 0.0 - 1.0 + completionTime?: number // milliseconds (player-specific) + + // Game-specific metrics (stored as JSON in DB) + metrics?: { + // Matching + moves?: number + matchedPairs?: number + difficulty?: number + + // Complement Race + streak?: number + correctAnswers?: number + totalQuestions?: number + + // Memory Quiz + correct?: number + incorrect?: number + + // Card Sorting + exactMatches?: number + inversions?: number + lcsLength?: number + + // Rithmomachia + capturedPieces?: number + points?: number + + // Extensible for future games + [key: string]: unknown + } +} + +/** + * Stats update returned from API + */ +export interface StatsUpdate { + playerId: string + previousStats: PlayerStats + newStats: PlayerStats + changes: { + gamesPlayed: number + wins: number + losses: number + } +} + +export interface PlayerStats { + playerId: string + gamesPlayed: number + totalWins: number + totalLosses: number + bestTime: number | null + highestAccuracy: number + favoriteGameType: string | null + gameStats: PerGameStats + lastPlayedAt: number | null + createdAt: number + updatedAt: number +} +``` + +### 3. API Endpoints + +#### POST `/api/player-stats/record-game` + +Records a game result and updates player stats. + +**Request:** +```typescript +{ + gameResult: GameResult +} +``` + +**Response:** +```typescript +{ + success: true, + updates: StatsUpdate[] // One per player +} +``` + +**Logic:** +1. Validate game result structure +2. For each player result: + - Fetch or create player_stats record + - Increment games_played + - Increment wins/losses based on outcome + - **Special case**: If `metadata.isTeamVictory === true`, all players share win/loss + - Cooperative games: all win or all lose together + - Competitive games: individual outcomes + - Update best_time if improved + - Update highest_accuracy if improved + - Update game-specific stats in JSON + - Update favorite_game_type based on most played + - Set last_played_at +3. Return updates for all players + +**Example pseudo-code**: +```typescript +for (const playerResult of gameResult.playerResults) { + const stats = await getPlayerStats(playerResult.playerId) + + stats.gamesPlayed++ + + // Handle cooperative games specially + if (gameResult.metadata?.isTeamVictory !== undefined) { + // Cooperative: all players share outcome + if (playerResult.won) { + stats.totalWins++ + } else { + stats.totalLosses++ + } + } else { + // Competitive/Solo: individual outcome + if (playerResult.won) { + stats.totalWins++ + } else { + stats.totalLosses++ + } + } + + // ... rest of stats update +} +``` + +#### GET `/api/player-stats/:playerId` + +Fetch stats for a specific player. + +**Response:** +```typescript +{ + stats: PlayerStats +} +``` + +#### GET `/api/player-stats` + +Fetch stats for all current user's players. + +**Response:** +```typescript +{ + playerStats: PlayerStats[] +} +``` + +### 4. React Hooks + +#### `useRecordGameResult()` + +Main hook that games use to record results. + +```typescript +// src/hooks/useRecordGameResult.ts + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { GameResult, StatsUpdate } from '@/lib/arcade/stats/types' + +export function useRecordGameResult() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (gameResult: GameResult): Promise => { + const res = await fetch('/api/player-stats/record-game', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ gameResult }), + }) + + if (!res.ok) throw new Error('Failed to record game result') + + const data = await res.json() + return data.updates + }, + + onSuccess: (updates) => { + // Invalidate player stats queries to trigger refetch + queryClient.invalidateQueries({ queryKey: ['player-stats'] }) + + // Show success feedback (optional) + console.log('✅ Game result recorded:', updates) + }, + + onError: (error) => { + console.error('❌ Failed to record game result:', error) + }, + }) +} +``` + +#### `usePlayerStats(playerId?)` + +Fetch stats for a player (or all players if no ID). + +```typescript +// src/hooks/usePlayerStats.ts + +import { useQuery } from '@tanstack/react-query' +import type { PlayerStats } from '@/lib/arcade/stats/types' + +export function usePlayerStats(playerId?: string) { + return useQuery({ + queryKey: playerId ? ['player-stats', playerId] : ['player-stats'], + queryFn: async (): Promise => { + const url = playerId + ? `/api/player-stats/${playerId}` + : '/api/player-stats' + + const res = await fetch(url) + if (!res.ok) throw new Error('Failed to fetch player stats') + + const data = await res.json() + return playerId ? data.stats : data.playerStats + }, + }) +} +``` + +### 5. Game Integration Pattern + +Every arcade game should follow this pattern when completing: + +```typescript +// In results phase component (e.g., ResultsPhase.tsx) + +import { useRecordGameResult } from '@/hooks/useRecordGameResult' +import type { GameResult } from '@/lib/arcade/stats/types' + +export function ResultsPhase() { + const { state, activePlayers } = useGameContext() + const { mutate: recordGame, isPending } = useRecordGameResult() + + // Record game result on mount (once) + useEffect(() => { + if (state.phase === 'results' && !state.recorded) { + const gameResult: GameResult = { + gameType: 'matching', + completedAt: Date.now(), + duration: state.gameEndTime - state.gameStartTime, + playerResults: activePlayers.map(player => ({ + playerId: player.id, + won: player.id === winnerId, + score: player.matchCount, + accuracy: player.matchCount / state.totalPairs, + completionTime: player.completionTime, + })), + } + + recordGame(gameResult, { + onSuccess: () => { + // Mark as recorded to prevent duplicates + setState({ recorded: true }) + } + }) + } + }, [state.phase, state.recorded]) + + // Show loading state while recording + if (isPending) { + return
Saving results...
+ } + + // Show results UI + return
...
+} +``` + +## Implementation Plan + +### Phase 1: Foundation (Database & API) + +1. **Create database schema** + - File: `src/db/schema/player-stats.ts` + - Define `player_stats` table with Drizzle ORM + - Add type exports + +2. **Generate migration** + ```bash + npx drizzle-kit generate:sqlite + ``` + +3. **Create type definitions** + - File: `src/lib/arcade/stats/types.ts` + - Define `GameResult`, `PlayerGameResult`, `StatsUpdate`, `PlayerStats` + +4. **Build API endpoint** + - File: `src/app/api/player-stats/record-game/route.ts` + - Implement POST handler with validation + - Handle per-player stat updates + - Transaction safety + +5. **Build query endpoints** + - File: `src/app/api/player-stats/route.ts` (GET all) + - File: `src/app/api/player-stats/[playerId]/route.ts` (GET one) + +### Phase 2: React Hooks & Integration + +6. **Create React hooks** + - File: `src/hooks/useRecordGameResult.ts` + - File: `src/hooks/usePlayerStats.ts` + +7. **Update GameModeContext** + - Expose helper to get player stats map + - Integrate with usePlayerStats hook + +### Phase 3: Matching Game Integration + +8. **Analyze matching game completion flow** + - Find where game completes + - Identify winner calculation + - Map state to GameResult format + +9. **Integrate stats recording** + - Add useRecordGameResult to ResultsPhase + - Build GameResult from game state + - Handle recording state to prevent duplicates + +10. **Test matching game stats** + - Play solo game, verify stats update + - Play multiplayer game, verify all players update + - Check accuracy calculations + - Check time tracking + +### Phase 4: UI Updates + +11. **Update /games page** + - Fetch per-player stats with usePlayerStats + - Display correct stats for each player card + - Remove dependency on global user profile + +12. **Add stats visualization** + - Per-game breakdown + - Win/loss ratio + - Performance trends + +### Phase 5: Documentation & Rollout + +13. **Document integration pattern** + - Create guide for adding stats to other games + - Code examples + - Common pitfalls + +14. **Roll out to other games** + - Complement Race + - Memory Quiz + - Card Sorting + - (Future games) + +## Data Migration Strategy + +### Handling Existing `user_stats` + +**Option A: Drop the table** +- Simple, clean break +- No historical data + +**Option B: Migrate to player stats** +- For each user with stats, assign to their first/active player +- More complex but preserves history + +**Recommendation**: Option A (drop it) since: +- Very new feature, unlikely much data exists +- Cleaner architecture +- Users can rebuild stats by playing + +### Migration SQL + +```sql +-- Drop old user_stats table +DROP TABLE IF EXISTS user_stats; + +-- Create new player_stats table +-- (Drizzle migration will handle this) +``` + +## Testing Strategy + +### Unit Tests + +- `GameResult` validation +- Stats calculation logic +- JSON merge for per-game stats +- Favorite game detection + +### Integration Tests + +- API endpoint: record game, verify DB update +- API endpoint: fetch stats, verify response +- React hook: record game, verify cache invalidation + +### E2E Tests + +- Play matching game solo, check stats on /games page +- Play matching game multiplayer, verify each player's stats +- Verify stats persist across sessions + +## Success Criteria + +✅ Player stats save correctly after game completion +✅ Each player maintains separate stats +✅ /games page displays correct per-player stats +✅ Stats survive page refresh +✅ Multiplayer games update all participants +✅ Framework is reusable for other games +✅ No duplicate recordings +✅ Performance acceptable (< 200ms to record) + +## Open Questions + +1. **Leaderboards?** - Future consideration, need global rankings +2. **Historical games?** - Store individual game records or just aggregates? +3. **Stats reset?** - Should users be able to reset player stats? +4. **Achievements?** - Track milestones? (100 games, 50 wins, etc.) + +## File Structure + +``` +src/ +├── db/ +│ └── schema/ +│ └── player-stats.ts # NEW: Drizzle schema +├── lib/ +│ └── arcade/ +│ └── stats/ +│ ├── types.ts # NEW: Type definitions +│ └── utils.ts # NEW: Helper functions +├── hooks/ +│ ├── useRecordGameResult.ts # NEW: Record game hook +│ └── usePlayerStats.ts # NEW: Fetch stats hook +├── app/ +│ └── api/ +│ └── player-stats/ +│ ├── route.ts # NEW: GET all +│ ├── record-game/ +│ │ └── route.ts # NEW: POST record +│ └── [playerId]/ +│ └── route.ts # NEW: GET one +└── arcade-games/ + └── matching/ + └── components/ + └── ResultsPhase.tsx # MODIFY: Add stats recording + +.claude/ +└── PER_PLAYER_STATS_ARCHITECTURE.md # THIS FILE +``` + +## Next Steps + +1. Review this plan with user +2. Create database schema and types +3. Build API endpoints +4. Create React hooks +5. Integrate with matching game +6. Test thoroughly +7. Roll out to other games + +--- + +**Document Status**: Draft for review +**Last Updated**: 2025-01-03 +**Owner**: Claude Code diff --git a/apps/web/drizzle/0013_add_player_stats.sql b/apps/web/drizzle/0013_add_player_stats.sql new file mode 100644 index 00000000..8a2c30dc --- /dev/null +++ b/apps/web/drizzle/0013_add_player_stats.sql @@ -0,0 +1,17 @@ +-- Migration: Add player_stats table +-- Per-player game statistics tracking + +CREATE TABLE `player_stats` ( + `player_id` text PRIMARY KEY NOT NULL, + `games_played` integer DEFAULT 0 NOT NULL, + `total_wins` integer DEFAULT 0 NOT NULL, + `total_losses` integer DEFAULT 0 NOT NULL, + `best_time` integer, + `highest_accuracy` real DEFAULT 0 NOT NULL, + `favorite_game_type` text, + `game_stats` text DEFAULT '{}' NOT NULL, + `last_played_at` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`player_id`) REFERENCES `players`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/apps/web/src/app/api/player-stats/[playerId]/route.ts b/apps/web/src/app/api/player-stats/[playerId]/route.ts new file mode 100644 index 00000000..c20df36e --- /dev/null +++ b/apps/web/src/app/api/player-stats/[playerId]/route.ts @@ -0,0 +1,111 @@ +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { db } from '@/db' +import type { GameStatsBreakdown } from '@/db/schema/player-stats' +import { playerStats } from '@/db/schema/player-stats' +import { players } from '@/db/schema/players' +import type { GetPlayerStatsResponse, PlayerStatsData } from '@/lib/arcade/stats/types' +import { getViewerId } from '@/lib/viewer' + +/** + * GET /api/player-stats/[playerId] + * + * Fetches stats for a specific player (must be owned by current user). + */ +export async function GET(_request: Request, { params }: { params: { playerId: string } }) { + try { + const { playerId } = params + + // 1. Authenticate user + const viewerId = await getViewerId() + if (!viewerId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // 2. Verify player belongs to user + const player = await db + .select() + .from(players) + .where(eq(players.id, playerId)) + .limit(1) + .then((rows) => rows[0]) + + if (!player) { + return NextResponse.json({ error: 'Player not found' }, { status: 404 }) + } + + if (player.userId !== viewerId) { + return NextResponse.json( + { error: 'Forbidden: player belongs to another user' }, + { status: 403 } + ) + } + + // 3. Fetch player stats + const stats = await db + .select() + .from(playerStats) + .where(eq(playerStats.playerId, playerId)) + .limit(1) + .then((rows) => rows[0]) + + const playerStatsData: PlayerStatsData = stats + ? convertToPlayerStatsData(stats) + : createDefaultPlayerStats(playerId) + + // 4. Return response + const response: GetPlayerStatsResponse = { + stats: playerStatsData, + } + + return NextResponse.json(response) + } catch (error) { + console.error('❌ Failed to fetch player stats:', error) + return NextResponse.json( + { + error: 'Failed to fetch player stats', + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ) + } +} + +/** + * Convert DB record to PlayerStatsData + */ +function convertToPlayerStatsData(dbStats: typeof playerStats.$inferSelect): PlayerStatsData { + return { + playerId: dbStats.playerId, + gamesPlayed: dbStats.gamesPlayed, + totalWins: dbStats.totalWins, + totalLosses: dbStats.totalLosses, + bestTime: dbStats.bestTime, + highestAccuracy: dbStats.highestAccuracy, + favoriteGameType: dbStats.favoriteGameType, + gameStats: (dbStats.gameStats as Record) || {}, + lastPlayedAt: dbStats.lastPlayedAt, + createdAt: dbStats.createdAt, + updatedAt: dbStats.updatedAt, + } +} + +/** + * Create default player stats for new player + */ +function createDefaultPlayerStats(playerId: string): PlayerStatsData { + const now = new Date() + return { + playerId, + gamesPlayed: 0, + totalWins: 0, + totalLosses: 0, + bestTime: null, + highestAccuracy: 0, + favoriteGameType: null, + gameStats: {}, + lastPlayedAt: null, + createdAt: now, + updatedAt: now, + } +} diff --git a/apps/web/src/app/api/player-stats/record-game/route.ts b/apps/web/src/app/api/player-stats/record-game/route.ts new file mode 100644 index 00000000..c51dbf2b --- /dev/null +++ b/apps/web/src/app/api/player-stats/record-game/route.ts @@ -0,0 +1,277 @@ +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { db } from '@/db' +import type { GameStatsBreakdown } from '@/db/schema/player-stats' +import { playerStats } from '@/db/schema/player-stats' +import type { + GameResult, + PlayerGameResult, + PlayerStatsData, + RecordGameRequest, + RecordGameResponse, + StatsUpdate, +} from '@/lib/arcade/stats/types' +import { getViewerId } from '@/lib/viewer' + +/** + * POST /api/player-stats/record-game + * + * Records a game result and updates player stats for all participants. + * Supports cooperative games (team wins/losses) and competitive games. + */ +export async function POST(request: Request) { + try { + // 1. Authenticate user + const viewerId = await getViewerId() + if (!viewerId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // 2. Parse and validate request + const body: RecordGameRequest = await request.json() + const { gameResult } = body + + if (!gameResult || !gameResult.playerResults || gameResult.playerResults.length === 0) { + return NextResponse.json( + { error: 'Invalid game result: playerResults required' }, + { status: 400 } + ) + } + + if (!gameResult.gameType) { + return NextResponse.json({ error: 'Invalid game result: gameType required' }, { status: 400 }) + } + + // 3. Process each player's result + const updates: StatsUpdate[] = [] + + for (const playerResult of gameResult.playerResults) { + const update = await recordPlayerResult(gameResult, playerResult) + updates.push(update) + } + + // 4. Return success response + const response: RecordGameResponse = { + success: true, + updates, + } + + return NextResponse.json(response) + } catch (error) { + console.error('❌ Failed to record game result:', error) + return NextResponse.json( + { + error: 'Failed to record game result', + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ) + } +} + +/** + * Records stats for a single player's game result + */ +async function recordPlayerResult( + gameResult: GameResult, + playerResult: PlayerGameResult +): Promise { + const { playerId } = playerResult + + // 1. Fetch or create player stats + const existingStats = await db + .select() + .from(playerStats) + .where(eq(playerStats.playerId, playerId)) + .limit(1) + .then((rows) => rows[0]) + + const previousStats: PlayerStatsData = existingStats + ? convertToPlayerStatsData(existingStats) + : createDefaultPlayerStats(playerId) + + // 2. Calculate new stats + const newStats: PlayerStatsData = { ...previousStats } + + // Always increment games played + newStats.gamesPlayed++ + + // Handle wins/losses (cooperative vs competitive) + if (gameResult.metadata?.isTeamVictory !== undefined) { + // Cooperative game: all players share outcome + if (playerResult.won) { + newStats.totalWins++ + } else { + newStats.totalLosses++ + } + } else { + // Competitive/Solo: individual outcome + if (playerResult.won) { + newStats.totalWins++ + } else { + newStats.totalLosses++ + } + } + + // Update best time (if provided and improved) + if (playerResult.completionTime) { + if (!newStats.bestTime || playerResult.completionTime < newStats.bestTime) { + newStats.bestTime = playerResult.completionTime + } + } + + // Update highest accuracy (if provided and improved) + if (playerResult.accuracy !== undefined && playerResult.accuracy > newStats.highestAccuracy) { + newStats.highestAccuracy = playerResult.accuracy + } + + // Update per-game stats (JSON) + const gameType = gameResult.gameType + const currentGameStats: GameStatsBreakdown = newStats.gameStats[gameType] || { + gamesPlayed: 0, + wins: 0, + losses: 0, + bestTime: null, + highestAccuracy: 0, + averageScore: 0, + lastPlayed: 0, + } + + currentGameStats.gamesPlayed++ + if (playerResult.won) { + currentGameStats.wins++ + } else { + currentGameStats.losses++ + } + + // Update game-specific best time + if (playerResult.completionTime) { + if (!currentGameStats.bestTime || playerResult.completionTime < currentGameStats.bestTime) { + currentGameStats.bestTime = playerResult.completionTime + } + } + + // Update game-specific highest accuracy + if ( + playerResult.accuracy !== undefined && + playerResult.accuracy > currentGameStats.highestAccuracy + ) { + currentGameStats.highestAccuracy = playerResult.accuracy + } + + // Update average score + if (playerResult.score !== undefined) { + const previousTotal = currentGameStats.averageScore * (currentGameStats.gamesPlayed - 1) + currentGameStats.averageScore = + (previousTotal + playerResult.score) / currentGameStats.gamesPlayed + } + + currentGameStats.lastPlayed = gameResult.completedAt + + newStats.gameStats[gameType] = currentGameStats + + // Update favorite game type (most played) + newStats.favoriteGameType = getMostPlayedGame(newStats.gameStats) + + // Update timestamps + newStats.lastPlayedAt = new Date(gameResult.completedAt) + newStats.updatedAt = new Date() + + // 3. Save to database + if (existingStats) { + // Update existing record + await db + .update(playerStats) + .set({ + gamesPlayed: newStats.gamesPlayed, + totalWins: newStats.totalWins, + totalLosses: newStats.totalLosses, + bestTime: newStats.bestTime, + highestAccuracy: newStats.highestAccuracy, + favoriteGameType: newStats.favoriteGameType, + gameStats: newStats.gameStats as any, // Drizzle JSON type + lastPlayedAt: newStats.lastPlayedAt, + updatedAt: newStats.updatedAt, + }) + .where(eq(playerStats.playerId, playerId)) + } else { + // Insert new record + await db.insert(playerStats).values({ + playerId: newStats.playerId, + gamesPlayed: newStats.gamesPlayed, + totalWins: newStats.totalWins, + totalLosses: newStats.totalLosses, + bestTime: newStats.bestTime, + highestAccuracy: newStats.highestAccuracy, + favoriteGameType: newStats.favoriteGameType, + gameStats: newStats.gameStats as any, + lastPlayedAt: newStats.lastPlayedAt, + createdAt: newStats.createdAt, + updatedAt: newStats.updatedAt, + }) + } + + // 4. Return update summary + return { + playerId, + previousStats, + newStats, + changes: { + gamesPlayed: newStats.gamesPlayed - previousStats.gamesPlayed, + wins: newStats.totalWins - previousStats.totalWins, + losses: newStats.totalLosses - previousStats.totalLosses, + }, + } +} + +/** + * Convert DB record to PlayerStatsData + */ +function convertToPlayerStatsData(dbStats: typeof playerStats.$inferSelect): PlayerStatsData { + return { + playerId: dbStats.playerId, + gamesPlayed: dbStats.gamesPlayed, + totalWins: dbStats.totalWins, + totalLosses: dbStats.totalLosses, + bestTime: dbStats.bestTime, + highestAccuracy: dbStats.highestAccuracy, + favoriteGameType: dbStats.favoriteGameType, + gameStats: (dbStats.gameStats as Record) || {}, + lastPlayedAt: dbStats.lastPlayedAt, + createdAt: dbStats.createdAt, + updatedAt: dbStats.updatedAt, + } +} + +/** + * Create default player stats for new player + */ +function createDefaultPlayerStats(playerId: string): PlayerStatsData { + const now = new Date() + return { + playerId, + gamesPlayed: 0, + totalWins: 0, + totalLosses: 0, + bestTime: null, + highestAccuracy: 0, + favoriteGameType: null, + gameStats: {}, + lastPlayedAt: null, + createdAt: now, + updatedAt: now, + } +} + +/** + * Determine most-played game from game stats + */ +function getMostPlayedGame(gameStats: Record): string | null { + const games = Object.entries(gameStats) + if (games.length === 0) return null + + return games.reduce((mostPlayed, [gameType, stats]) => { + const mostPlayedStats = gameStats[mostPlayed] + return stats.gamesPlayed > (mostPlayedStats?.gamesPlayed || 0) ? gameType : mostPlayed + }, games[0][0]) +} diff --git a/apps/web/src/app/api/player-stats/route.ts b/apps/web/src/app/api/player-stats/route.ts new file mode 100644 index 00000000..9cce5e62 --- /dev/null +++ b/apps/web/src/app/api/player-stats/route.ts @@ -0,0 +1,102 @@ +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { db } from '@/db' +import type { GameStatsBreakdown } from '@/db/schema/player-stats' +import { playerStats } from '@/db/schema/player-stats' +import { players } from '@/db/schema/players' +import type { GetAllPlayerStatsResponse, PlayerStatsData } from '@/lib/arcade/stats/types' +import { getViewerId } from '@/lib/viewer' + +/** + * GET /api/player-stats + * + * Fetches stats for all of the current user's players. + */ +export async function GET() { + try { + // 1. Authenticate user + const viewerId = await getViewerId() + if (!viewerId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // 2. Fetch all user's players + const userPlayers = await db.select().from(players).where(eq(players.userId, viewerId)) + + const playerIds = userPlayers.map((p) => p.id) + + // 3. Fetch stats for all players + const allStats: PlayerStatsData[] = [] + + for (const playerId of playerIds) { + const stats = await db + .select() + .from(playerStats) + .where(eq(playerStats.playerId, playerId)) + .limit(1) + .then((rows) => rows[0]) + + if (stats) { + allStats.push(convertToPlayerStatsData(stats)) + } else { + // Player exists but has no stats yet + allStats.push(createDefaultPlayerStats(playerId)) + } + } + + // 4. Return response + const response: GetAllPlayerStatsResponse = { + playerStats: allStats, + } + + return NextResponse.json(response) + } catch (error) { + console.error('❌ Failed to fetch player stats:', error) + return NextResponse.json( + { + error: 'Failed to fetch player stats', + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ) + } +} + +/** + * Convert DB record to PlayerStatsData + */ +function convertToPlayerStatsData(dbStats: typeof playerStats.$inferSelect): PlayerStatsData { + return { + playerId: dbStats.playerId, + gamesPlayed: dbStats.gamesPlayed, + totalWins: dbStats.totalWins, + totalLosses: dbStats.totalLosses, + bestTime: dbStats.bestTime, + highestAccuracy: dbStats.highestAccuracy, + favoriteGameType: dbStats.favoriteGameType, + gameStats: (dbStats.gameStats as Record) || {}, + lastPlayedAt: dbStats.lastPlayedAt, + createdAt: dbStats.createdAt, + updatedAt: dbStats.updatedAt, + } +} + +/** + * Create default player stats for new player + */ +function createDefaultPlayerStats(playerId: string): PlayerStatsData { + const now = new Date() + return { + playerId, + gamesPlayed: 0, + totalWins: 0, + totalLosses: 0, + bestTime: null, + highestAccuracy: 0, + favoriteGameType: null, + gameStats: {}, + lastPlayedAt: null, + createdAt: now, + updatedAt: now, + } +} diff --git a/apps/web/src/app/create/page.tsx b/apps/web/src/app/create/flashcards/page.tsx similarity index 100% rename from apps/web/src/app/create/page.tsx rename to apps/web/src/app/create/flashcards/page.tsx diff --git a/apps/web/src/app/games/page.tsx b/apps/web/src/app/games/page.tsx index f3a54f79..6bebc1fa 100644 --- a/apps/web/src/app/games/page.tsx +++ b/apps/web/src/app/games/page.tsx @@ -5,13 +5,15 @@ import useEmblaCarousel from 'embla-carousel-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { PageWithNav } from '@/components/PageWithNav' +import { GamePreview } from '@/components/GamePreview' import { getAvailableGames } from '@/lib/arcade/game-registry' import { css } from '../../../styled-system/css' import { useFullscreen } from '../../contexts/FullscreenContext' import { useGameMode } from '../../contexts/GameModeContext' import { useUserProfile } from '../../contexts/UserProfileContext' +import { useAllPlayerStats } from '@/hooks/usePlayerStats' function GamesPageContent() { const t = useTranslations('games') @@ -27,11 +29,28 @@ function GamesPageContent() { return aTime - bTime }) + // Fetch per-player stats + const { data: playerStatsArray, isLoading: statsLoading } = useAllPlayerStats() + + // Create a map of playerId -> stats for easy lookup + const playerStatsMap = useMemo(() => { + const map = new Map() + if (playerStatsArray) { + for (const stats of playerStatsArray) { + map.set(stats.playerId, stats) + } + } + return map + }, [playerStatsArray]) + // Get available games const availableGames = getAvailableGames() - // Check if user has any stats to show - const hasStats = profile.gamesPlayed > 0 + // Check if user has any stats to show (check if ANY player has stats) + const hasStats = + playerStatsArray && + playerStatsArray.length > 0 && + playerStatsArray.some((s) => s.gamesPlayed > 0) // Embla carousel setup for games hero carousel with autoplay const [gamesEmblaRef, gamesEmblaApi] = useEmblaCarousel( @@ -113,859 +132,790 @@ function GamesPageContent() { })} /> + {/* Enter Arcade Button */} +
+ +
+ {/* Games Hero Carousel - Full Width */}
-

- 🎮 Available Games -

- - {/* Carousel */}
+
+
+ {/* Dynamic Game Cards */} + {availableGames.map((game, index) => { + const isActive = index === gamesSelectedIndex + const manifest = game.manifest + const GameComp = game.GameComponent + const Provider = game.Provider + + return ( +
gamesEmblaApi?.scrollTo(index)} + data-element={`game-card-${manifest.name}`} + > + {/* Live Game Demo */} +
+ +
+ + {/* Overlay with game info - shorter gradient that overlaps game */} +
+ {/* Game Icon */} +
+ {manifest.icon} +
+ + {/* Game Title */} +

+ {manifest.displayName} +

+ + {/* Game Info Badges */} +
+ + {manifest.difficulty} + + + {manifest.maxPlayers === 1 + ? '1 Player' + : `1-${manifest.maxPlayers} Players`} + +
+ + {/* Description */} +

+ {manifest.description} +

+
+
+ ) + })} +
+
+ + {/* Navigation Dots with Game Icons */}
- {availableGames.map((game) => { - const gameIndex = availableGames.indexOf(game) - const isActive = gameIndex === gamesSelectedIndex - - return ( -
- -
- {/* Dark gradient overlay for readability */} -
- - {/* Content */} -
- {/* Icon and Title */} -
-
- {game.manifest.icon} -
-
-

- {game.manifest.displayName} -

-

- {game.manifest.difficulty} •{' '} - {game.manifest.maxPlayers === 1 - ? 'Solo' - : `1-${game.manifest.maxPlayers} Players`} -

-
-
- - {/* Description */} -

- {game.manifest.description} -

- - {/* Chips */} -
- {game.manifest.chips.map((chip) => ( - - {chip} - - ))} -
-
-
- -
- ) - })} + {availableGames.map((game, index) => ( + + ))}
- - {/* Navigation Dots */} -
- {availableGames.map((game, index) => ( - - ))} -
- {/* Rest of content - constrained width */} -
- {/* Enter Arcade Button */} -
+ {/* Character Showcase Section - Only show if user has stats */} + {hasStats && ( + <> + {/* Character Showcase Header */}
- {/* Gradient background */}
- -

- {t('enterArcade.title')} + {t('champions.title')}

-

- {t('enterArcade.description')} -

- - - -

- {t('enterArcade.subtitle')} + {t('champions.subtitle')}

-
-
- {/* Character Showcase Section - Only show if user has stats */} - {hasStats && ( - <> - {/* Character Showcase Header */} + {/* Player Carousel */}
+
+
+ {/* Dynamic Player Character Cards */} + {allPlayers.map((player, index) => { + const isActive = index === selectedIndex + + // Rotate through different color schemes for visual variety + const colorSchemes = [ + { + border: 'rgba(59, 130, 246, 0.3)', + shadow: 'rgba(59, 130, 246, 0.1)', + gradient: 'linear-gradient(90deg, #3b82f6, #1d4ed8)', + statBg: 'linear-gradient(135deg, #dbeafe, #bfdbfe)', + statBorder: 'blue.200', + statColor: 'blue.800', + levelColor: 'blue.700', + }, + { + border: 'rgba(139, 92, 246, 0.3)', + shadow: 'rgba(139, 92, 246, 0.1)', + gradient: 'linear-gradient(90deg, #8b5cf6, #7c3aed)', + statBg: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)', + statBorder: 'purple.200', + statColor: 'purple.800', + levelColor: 'purple.700', + }, + { + border: 'rgba(16, 185, 129, 0.3)', + shadow: 'rgba(16, 185, 129, 0.1)', + gradient: 'linear-gradient(90deg, #10b981, #059669)', + statBg: 'linear-gradient(135deg, #d1fae5, #a7f3d0)', + statBorder: 'green.200', + statColor: 'green.800', + levelColor: 'green.700', + }, + { + border: 'rgba(245, 158, 11, 0.3)', + shadow: 'rgba(245, 158, 11, 0.1)', + gradient: 'linear-gradient(90deg, #f59e0b, #d97706)', + statBg: 'linear-gradient(135deg, #fef3c7, #fde68a)', + statBorder: 'yellow.200', + statColor: 'yellow.800', + levelColor: 'yellow.700', + }, + ] + const theme = colorSchemes[index % colorSchemes.length] + + // Get per-player stats + const playerStats = playerStatsMap.get(player.id) + const gamesPlayed = playerStats?.gamesPlayed || 0 + const totalWins = playerStats?.totalWins || 0 + + return ( +
emblaApi?.scrollTo(index)} + data-element={`player-card-${index}`} + > + {/* Gradient Border */} +
+ + {/* Character Display */} +
+
+ {player.emoji} +
+

+ {player.name} +

+
+ + {/* Stats */} +
+
+
+ {gamesPlayed} +
+
+ {t('champions.stats.gamesPlayed')} +
+
+ +
+
+ {totalWins} +
+
+ {t('champions.stats.victories')} +
+
+
+ + {/* Level Progress */} +
+
+ + {t('champions.stats.level', { + level: Math.floor(gamesPlayed / 5) + 1, + })} + + + {t('champions.stats.xp', { + current: gamesPlayed % 5, + total: 5, + })} + +
+
+
+
+
+ + {/* Quick Customize Button */} + +
+ ) + })} +
+
+ + {/* Navigation Dots */} +
+ {allPlayers.map((player, index) => ( + + ))} +
+
+
+ + )} + + {/* Character vs Character Dashboard - Only show if user has stats */} + {hasStats && ( +
+
+ {/* Head-to-Head Stats */} +
-

- {t('champions.title')} -

+ {t('dashboard.headToHead.title')} +

- {t('champions.subtitle')} + {t('dashboard.headToHead.subtitle')}

- {/* Player Carousel */}
-
-
- {/* Dynamic Player Character Cards */} - {allPlayers.map((player, index) => { - const isActive = index === selectedIndex - - // Rotate through different color schemes for visual variety - const colorSchemes = [ - { - border: 'rgba(59, 130, 246, 0.3)', - shadow: 'rgba(59, 130, 246, 0.1)', - gradient: 'linear-gradient(90deg, #3b82f6, #1d4ed8)', - statBg: 'linear-gradient(135deg, #dbeafe, #bfdbfe)', - statBorder: 'blue.200', - statColor: 'blue.800', - levelColor: 'blue.700', - }, - { - border: 'rgba(139, 92, 246, 0.3)', - shadow: 'rgba(139, 92, 246, 0.1)', - gradient: 'linear-gradient(90deg, #8b5cf6, #7c3aed)', - statBg: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)', - statBorder: 'purple.200', - statColor: 'purple.800', - levelColor: 'purple.700', - }, - { - border: 'rgba(16, 185, 129, 0.3)', - shadow: 'rgba(16, 185, 129, 0.1)', - gradient: 'linear-gradient(90deg, #10b981, #059669)', - statBg: 'linear-gradient(135deg, #d1fae5, #a7f3d0)', - statBorder: 'green.200', - statColor: 'green.800', - levelColor: 'green.700', - }, - { - border: 'rgba(245, 158, 11, 0.3)', - shadow: 'rgba(245, 158, 11, 0.1)', - gradient: 'linear-gradient(90deg, #f59e0b, #d97706)', - statBg: 'linear-gradient(135deg, #fef3c7, #fde68a)', - statBorder: 'yellow.200', - statColor: 'yellow.800', - levelColor: 'yellow.700', - }, - ] - const theme = colorSchemes[index % colorSchemes.length] - - return ( -
emblaApi?.scrollTo(index)} - data-element={`player-card-${index}`} - > - {/* Gradient Border */} -
- - {/* Character Display */} -
-
- {player.emoji} -
-

- {player.name} -

-
- - {/* Stats */} -
-
-
- {profile.gamesPlayed} -
-
- {t('champions.stats.gamesPlayed')} -
-
- -
-
- {profile.totalWins} -
-
- {t('champions.stats.victories')} -
-
-
- - {/* Level Progress */} -
-
- - {t('champions.stats.level', { - level: Math.floor(profile.gamesPlayed / 5) + 1, - })} - - - {t('champions.stats.xp', { - current: profile.gamesPlayed % 5, - total: 5, - })} - -
-
-
-
-
- - {/* Quick Customize Button */} - -
- ) - })} -
-
- - {/* Navigation Dots */} -
- {allPlayers.map((player, index) => ( - - ))} -
-
-
- - )} - - {/* Character vs Character Dashboard - Only show if user has stats */} - {hasStats && ( -
-
- {/* Head-to-Head Stats */} -
-
-

- {t('dashboard.headToHead.title')} -

-

- {t('dashboard.headToHead.subtitle')} -

-
+ {allPlayers.slice(0, 2).map((player, idx) => { + const playerStats = playerStatsMap.get(player.id) + const totalWins = playerStats?.totalWins || 0 -
- {allPlayers.slice(0, 2).map((player, idx) => ( + return (
- {Math.floor(profile.totalWins * (idx === 0 ? 0.6 : 0.4))} + {totalWins}
)} - ))} -
+ ) + })} +
-
+ {t('dashboard.headToHead.lastPlayed')} +
+
+ + {/* Recent Achievements */} +
+
+

+ {t('dashboard.achievements.title')} +

+

- {t('dashboard.headToHead.lastPlayed')} -

+ {t('dashboard.achievements.subtitle')} +

- {/* Recent Achievements */}
-
-

- {t('dashboard.achievements.title')} -

-

- {t('dashboard.achievements.subtitle')} -

-
- -
- {allPlayers.slice(0, 2).map((player, idx) => ( -
- {player.emoji} -
-
- {idx === 0 - ? t('dashboard.achievements.firstWin.title') - : t('dashboard.achievements.speedDemon.title')} -
-
- {idx === 0 - ? t('dashboard.achievements.firstWin.description') - : t('dashboard.achievements.speedDemon.description')} -
-
-
- ))} -
-
- - {/* Challenge System */} -
-
-

- {t('dashboard.challenges.title')} -

-

- {t('dashboard.challenges.subtitle')} -

-
- - {allPlayers.length >= 2 && ( + {allPlayers.slice(0, 2).map((player, idx) => (
-
- {allPlayers[0].emoji} - {player.emoji} +
+
- {t('dashboard.challenges.challengesText')} - - {allPlayers[1].emoji} -
-
- "{t('dashboard.challenges.exampleChallenge')}" -
-
- {t('dashboard.challenges.currentBest', { score: 850 })} + {idx === 0 + ? t('dashboard.achievements.firstWin.title') + : t('dashboard.achievements.speedDemon.title')} +
+
+ {idx === 0 + ? t('dashboard.achievements.firstWin.description') + : t('dashboard.achievements.speedDemon.description')} +
- )} - - + ))}
+ + {/* Challenge System */} +
+
+

+ {t('dashboard.challenges.title')} +

+

+ {t('dashboard.challenges.subtitle')} +

+
+ + {allPlayers.length >= 2 && ( +
+
+ {allPlayers[0].emoji} + + {t('dashboard.challenges.challengesText')} + + {allPlayers[1].emoji} +
+
+ "{t('dashboard.challenges.exampleChallenge')}" +
+
+ {t('dashboard.challenges.currentBest', { score: 850 })} +
+
+ )} + + +
- )} -
+
+ )}
) } diff --git a/apps/web/src/arcade-games/matching/components/ResultsPhase.tsx b/apps/web/src/arcade-games/matching/components/ResultsPhase.tsx index 47e6924f..4264ae01 100644 --- a/apps/web/src/arcade-games/matching/components/ResultsPhase.tsx +++ b/apps/web/src/arcade-games/matching/components/ResultsPhase.tsx @@ -1,15 +1,19 @@ 'use client' +import { useEffect } from 'react' import { useRouter } from 'next/navigation' import { css } from '../../../../styled-system/css' import { useGameMode } from '@/contexts/GameModeContext' import { useMatching } from '../Provider' import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring' +import { useRecordGameResult } from '@/hooks/useRecordGameResult' +import type { GameResult } from '@/lib/arcade/stats/types' export function ResultsPhase() { const router = useRouter() const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching() const { players: playerMap, activePlayers: activePlayerIds } = useGameMode() + const { mutate: recordGameResult } = useRecordGameResult() // Get active player data array const activePlayerData = Array.from(activePlayerIds) @@ -28,6 +32,45 @@ export function ResultsPhase() { const multiplayerResult = gameMode === 'multiplayer' ? getMultiplayerWinner(state, activePlayers) : null + // Record game stats when results are shown + useEffect(() => { + if (!state.gameEndTime || !state.gameStartTime) return + + // Build game result + const gameResult: GameResult = { + gameType: 'matching', + playerResults: activePlayerData.map((player) => { + const isWinner = gameMode === 'single' || multiplayerResult?.winners.includes(player.id) + const score = + gameMode === 'multiplayer' + ? multiplayerResult?.scores[player.id] || 0 + : state.matchedPairs + + return { + playerId: player.id, + won: isWinner || false, + score, + accuracy: analysis.statistics.accuracy / 100, // Convert percentage to 0-1 + completionTime: gameTime, + metrics: { + moves: state.moves, + matchedPairs: state.matchedPairs, + }, + } + }), + completedAt: state.gameEndTime, + duration: gameTime, + metadata: { + gameMode, + starRating: analysis.starRating, + grade: analysis.grade, + }, + } + + console.log('📊 Recording matching game result:', gameResult) + recordGameResult(gameResult) + }, []) // Empty deps - only record once when component mounts + return (
players.id, { onDelete: 'cascade' }), + + /** Total number of games played across all game types */ + gamesPlayed: integer('games_played').notNull().default(0), + + /** Total number of games won */ + totalWins: integer('total_wins').notNull().default(0), + + /** Total number of games lost */ + totalLosses: integer('total_losses').notNull().default(0), + + /** Best completion time in milliseconds (across all games) */ + bestTime: integer('best_time'), + + /** Highest accuracy percentage (0.0 - 1.0, across all games) */ + highestAccuracy: real('highest_accuracy').notNull().default(0), + + /** Player's most-played game type */ + favoriteGameType: text('favorite_game_type'), + + /** + * Per-game statistics breakdown (JSON) + * + * Structure: + * { + * "matching": { + * gamesPlayed: 10, + * wins: 5, + * losses: 5, + * bestTime: 45000, + * highestAccuracy: 0.95, + * averageScore: 12.5, + * lastPlayed: 1704326400000 + * }, + * "complement-race": { ... }, + * ... + * } + */ + gameStats: text('game_stats', { mode: 'json' }) + .notNull() + .default('{}') + .$type>(), + + /** When this player last played any game */ + lastPlayedAt: integer('last_played_at', { mode: 'timestamp' }), + + /** When this record was created */ + createdAt: integer('created_at', { mode: 'timestamp' }) + .notNull() + .$defaultFn(() => new Date()), + + /** When this record was last updated */ + updatedAt: integer('updated_at', { mode: 'timestamp' }) + .notNull() + .$defaultFn(() => new Date()), +}) + +/** + * Per-game stats breakdown stored in JSON + */ +export interface GameStatsBreakdown { + gamesPlayed: number + wins: number + losses: number + bestTime: number | null + highestAccuracy: number + averageScore: number + lastPlayed: number // timestamp +} + +export type PlayerStats = typeof playerStats.$inferSelect +export type NewPlayerStats = typeof playerStats.$inferInsert diff --git a/apps/web/src/hooks/usePlayerStats.ts b/apps/web/src/hooks/usePlayerStats.ts new file mode 100644 index 00000000..cfb133d9 --- /dev/null +++ b/apps/web/src/hooks/usePlayerStats.ts @@ -0,0 +1,87 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import type { + GetAllPlayerStatsResponse, + GetPlayerStatsResponse, + PlayerStatsData, +} from '@/lib/arcade/stats/types' +import { api } from '@/lib/queryClient' + +/** + * Hook to fetch stats for a specific player or all user's players + * + * Usage: + * ```tsx + * // Fetch all players' stats + * const { data, isLoading } = usePlayerStats() + * // data is PlayerStatsData[] + * + * // Fetch specific player's stats + * const { data, isLoading } = usePlayerStats('player-id') + * // data is PlayerStatsData + * ``` + */ +export function usePlayerStats(playerId?: string) { + return useQuery({ + queryKey: playerId ? ['player-stats', playerId] : ['player-stats'], + queryFn: async () => { + const url = playerId ? `player-stats/${playerId}` : 'player-stats' + + const res = await api(url) + if (!res.ok) { + throw new Error('Failed to fetch player stats') + } + + const data: GetPlayerStatsResponse | GetAllPlayerStatsResponse = await res.json() + + // Return single player stats or array of all stats + return 'stats' in data ? data.stats : data.playerStats + }, + }) +} + +/** + * Hook to fetch stats for all user's players (typed as array) + * + * Convenience wrapper around usePlayerStats() with better typing. + */ +export function useAllPlayerStats() { + const query = useQuery({ + queryKey: ['player-stats'], + queryFn: async () => { + const res = await api('player-stats') + if (!res.ok) { + throw new Error('Failed to fetch player stats') + } + + const data: GetAllPlayerStatsResponse = await res.json() + return data.playerStats + }, + }) + + return query +} + +/** + * Hook to fetch stats for a specific player (typed as single object) + * + * Convenience wrapper around usePlayerStats() with better typing. + */ +export function useSinglePlayerStats(playerId: string) { + const query = useQuery({ + queryKey: ['player-stats', playerId], + queryFn: async () => { + const res = await api(`player-stats/${playerId}`) + if (!res.ok) { + throw new Error('Failed to fetch player stats') + } + + const data: GetPlayerStatsResponse = await res.json() + return data.stats + }, + enabled: !!playerId, // Only run if playerId is provided + }) + + return query +} diff --git a/apps/web/src/hooks/useRecordGameResult.ts b/apps/web/src/hooks/useRecordGameResult.ts new file mode 100644 index 00000000..4854fdef --- /dev/null +++ b/apps/web/src/hooks/useRecordGameResult.ts @@ -0,0 +1,51 @@ +'use client' + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { GameResult, RecordGameResponse } from '@/lib/arcade/stats/types' +import { api } from '@/lib/queryClient' + +/** + * Hook to record a game result and update player stats + * + * Usage: + * ```tsx + * const { mutate: recordGame, isPending } = useRecordGameResult() + * + * recordGame(gameResult, { + * onSuccess: (updates) => { + * console.log('Stats recorded:', updates) + * } + * }) + * ``` + */ +export function useRecordGameResult() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (gameResult: GameResult): Promise => { + const res = await api('player-stats/record-game', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ gameResult }), + }) + + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Failed to record game result' })) + throw new Error(error.error || 'Failed to record game result') + } + + return res.json() + }, + + onSuccess: (response) => { + // Invalidate player stats queries to trigger refetch + queryClient.invalidateQueries({ queryKey: ['player-stats'] }) + + console.log('✅ Game result recorded successfully:', response.updates) + }, + + onError: (error) => { + console.error('❌ Failed to record game result:', error) + }, + }) +} diff --git a/apps/web/src/lib/arcade/stats/types.ts b/apps/web/src/lib/arcade/stats/types.ts new file mode 100644 index 00000000..2aa7004a --- /dev/null +++ b/apps/web/src/lib/arcade/stats/types.ts @@ -0,0 +1,153 @@ +/** + * Universal game stats types + * + * These types are used across ALL arcade games to record player performance. + * Supports: solo, competitive, cooperative, and head-to-head game modes. + * + * See: .claude/GAME_STATS_COMPARISON.md for detailed cross-game analysis + */ + +import type { GameStatsBreakdown } from '@/db/schema/player-stats' + +/** + * Standard game result that all arcade games must provide + * + * Supports: + * - 1-N players + * - Competitive (individual winners) + * - Cooperative (team wins/losses) + * - Solo completion + * - Head-to-head (2-player) + */ +export interface GameResult { + // Game identification + gameType: string // e.g., "matching", "complement-race", "memory-quiz" + + // Player results (supports 1-N players) + playerResults: PlayerGameResult[] + + // Timing + completedAt: number // timestamp + duration: number // milliseconds + + // Optional game-specific data + metadata?: { + // For cooperative games (Memory Quiz, Card Sorting collaborative) + // When true: all players share win/loss outcome + isTeamVictory?: boolean + + // For specific win conditions (Rithmomachia) + winCondition?: string // e.g., "HARMONY", "POINTS", "TIMEOUT" + + // For game modes + gameMode?: string // e.g., "solo", "competitive", "cooperative" + + // Extensible for other game-specific info + [key: string]: unknown + } +} + +/** + * Individual player result within a game + */ +export interface PlayerGameResult { + playerId: string + + // Outcome + won: boolean // For cooperative games: all players have same value + placement?: number // 1st, 2nd, 3rd place (for tournaments with 3+ players) + + // Performance + score?: number + accuracy?: number // 0.0 - 1.0 + completionTime?: number // milliseconds (player-specific) + + // Game-specific metrics (stored as JSON in DB) + metrics?: { + // Matching + moves?: number + matchedPairs?: number + difficulty?: number + + // Complement Race + streak?: number + correctAnswers?: number + totalQuestions?: number + + // Memory Quiz + correct?: number + incorrect?: number + + // Card Sorting + exactMatches?: number + inversions?: number + lcsLength?: number + + // Rithmomachia + capturedPieces?: number + points?: number + + // Extensible for future games + [key: string]: unknown + } +} + +/** + * Stats update returned from API after recording a game + */ +export interface StatsUpdate { + playerId: string + previousStats: PlayerStatsData + newStats: PlayerStatsData + changes: { + gamesPlayed: number + wins: number + losses: number + } +} + +/** + * Complete player stats data (from DB) + */ +export interface PlayerStatsData { + playerId: string + gamesPlayed: number + totalWins: number + totalLosses: number + bestTime: number | null + highestAccuracy: number + favoriteGameType: string | null + gameStats: Record + lastPlayedAt: Date | null + createdAt: Date + updatedAt: Date +} + +/** + * Request body for recording a game result + */ +export interface RecordGameRequest { + gameResult: GameResult +} + +/** + * Response from recording a game result + */ +export interface RecordGameResponse { + success: boolean + updates: StatsUpdate[] +} + +/** + * Response from fetching player stats + */ +export interface GetPlayerStatsResponse { + stats: PlayerStatsData +} + +/** + * Response from fetching all user's player stats + */ +export interface GetAllPlayerStatsResponse { + playerStats: PlayerStatsData[] +}