feat: add per-player stats tracking system
Implement comprehensive per-player statistics tracking across arcade games: - Database schema: player_stats table with per-player metrics - Migration: 0013_add_player_stats.sql for schema deployment - Type system: Universal GameResult types supporting all game modes (competitive, cooperative, solo, head-to-head) - API endpoints: - POST /api/player-stats/record-game - Record game results - GET /api/player-stats - Fetch all user's players' stats - GET /api/player-stats/[playerId] - Fetch specific player stats - React hooks: - useRecordGameResult() - Mutation hook with cache invalidation - usePlayerStats() - Query hooks for fetching stats - Game integration: Matching game now records stats on completion - UI updates: /games page displays per-player stats in player cards Stats tracked: games played, wins, losses, best time, accuracy, per-game breakdowns (JSON), favorite game type, last played date. Supports cooperative games via metadata.isTeamVictory flag where all players share win/loss outcome. Documentation added: - GAME_STATS_COMPARISON.md - Cross-game analysis - PER_PLAYER_STATS_ARCHITECTURE.md - System design - MATCHING_GAME_STATS_INTEGRATION.md - Integration guide 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9f51edfaa9
commit
613301cd13
|
|
@ -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
|
||||
|
|
@ -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 (
|
||||
<div className={css({
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
})}>
|
||||
<p>Saving results...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Or keep it subtle and just disable buttons:
|
||||
|
||||
```typescript
|
||||
// On the "Play Again" button
|
||||
<button
|
||||
disabled={isRecording}
|
||||
className={css({
|
||||
// ... styles ...
|
||||
opacity: isRecording ? 0.5 : 1,
|
||||
cursor: isRecording ? 'not-allowed' : 'pointer',
|
||||
})}
|
||||
onClick={resetGame}
|
||||
>
|
||||
{isRecording ? '💾 Saving...' : '🎮 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)
|
||||
|
|
@ -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<StatsUpdate[]> => {
|
||||
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<PlayerStats | PlayerStats[]> => {
|
||||
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 <div>Saving results...</div>
|
||||
}
|
||||
|
||||
// Show results UI
|
||||
return <div>...</div>
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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<string, GameStatsBreakdown>) || {},
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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<StatsUpdate> {
|
||||
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<string, GameStatsBreakdown>) || {},
|
||||
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, GameStatsBreakdown>): 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])
|
||||
}
|
||||
|
|
@ -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<string, GameStatsBreakdown>) || {},
|
||||
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,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 (
|
||||
<div
|
||||
className={css({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export * from './abacus-settings'
|
|||
export * from './arcade-rooms'
|
||||
export * from './arcade-sessions'
|
||||
export * from './players'
|
||||
export * from './player-stats'
|
||||
export * from './room-members'
|
||||
export * from './room-member-history'
|
||||
export * from './room-invitations'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { players } from './players'
|
||||
|
||||
/**
|
||||
* Player stats table - game statistics per player
|
||||
*
|
||||
* Tracks aggregate performance and per-game breakdowns for each player.
|
||||
* One-to-one with players table. Deleted when player is deleted (cascade).
|
||||
*/
|
||||
export const playerStats = sqliteTable('player_stats', {
|
||||
/** Primary key and foreign key to players table */
|
||||
playerId: text('player_id')
|
||||
.primaryKey()
|
||||
.references(() => 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<Record<string, GameStatsBreakdown>>(),
|
||||
|
||||
/** 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
|
||||
|
|
@ -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<PlayerStatsData | PlayerStatsData[]>({
|
||||
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<PlayerStatsData[]>({
|
||||
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<PlayerStatsData>({
|
||||
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
|
||||
}
|
||||
|
|
@ -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<RecordGameResponse> => {
|
||||
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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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<string, GameStatsBreakdown>
|
||||
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[]
|
||||
}
|
||||
Loading…
Reference in New Issue