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:
Thomas Hallock 2025-11-03 10:49:11 -06:00
parent 9f51edfaa9
commit 613301cd13
15 changed files with 3328 additions and 990 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[]
}