feature updates
This commit is contained in:
31
server/.env.example
Normal file
31
server/.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:fftcg_secret@localhost:5432/fftcg
|
||||
POSTGRES_PASSWORD=fftcg_secret
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=change-this-to-a-secure-random-string
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Server ports
|
||||
HTTP_PORT=3000
|
||||
WS_PORT=3001
|
||||
|
||||
# SMTP (Email)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=noreply@fftcg.local
|
||||
|
||||
# App URL (for email links)
|
||||
APP_URL=http://localhost:3000
|
||||
|
||||
# Game settings
|
||||
TURN_TIMEOUT_MS=120000
|
||||
HEARTBEAT_INTERVAL_MS=10000
|
||||
HEARTBEAT_TIMEOUT_MS=30000
|
||||
|
||||
# ELO settings
|
||||
ELO_K_FACTOR=32
|
||||
ELO_STARTING_RATING=1000
|
||||
30
server/.gitignore
vendored
Normal file
30
server/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Prisma
|
||||
prisma/migrations/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
57
server/Dockerfile
Normal file
57
server/Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY src/ ./src/
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate --schema=src/db/schema.prisma
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install curl for healthcheck
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Copy package files and install production dependencies only
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy Prisma schema for migrations
|
||||
COPY src/db/schema.prisma ./prisma/schema.prisma
|
||||
|
||||
# Generate Prisma client in production image
|
||||
RUN npx prisma generate --schema=prisma/schema.prisma
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create startup script
|
||||
RUN echo '#!/bin/sh' > /app/start.sh && \
|
||||
echo 'npx prisma db push --schema=prisma/schema.prisma --accept-data-loss' >> /app/start.sh && \
|
||||
echo 'node dist/index.js' >> /app/start.sh && \
|
||||
chmod +x /app/start.sh
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 3000 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
|
||||
# Start server
|
||||
CMD ["/app/start.sh"]
|
||||
64
server/docker-compose.yml
Normal file
64
server/docker-compose.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
game-server:
|
||||
build: .
|
||||
container_name: fftcg-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000" # REST API
|
||||
- "3001:3001" # WebSocket
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD:-fftcg_secret}@db:5432/fftcg
|
||||
- JWT_SECRET=${JWT_SECRET:-change-this-in-production}
|
||||
- SMTP_HOST=${SMTP_HOST:-smtp.gmail.com}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-noreply@fftcg.local}
|
||||
- APP_URL=${APP_URL:-http://localhost:3000}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: fftcg-db
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-fftcg_secret}
|
||||
- POSTGRES_DB=fftcg
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
# Uncomment to expose DB port for external access (debugging)
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
# Optional: Add pgAdmin for database management
|
||||
# Uncomment the following to enable
|
||||
#
|
||||
# pgadmin:
|
||||
# image: dpage/pgadmin4
|
||||
# container_name: fftcg-pgadmin
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "5050:80"
|
||||
# environment:
|
||||
# - PGADMIN_DEFAULT_EMAIL=admin@fftcg.local
|
||||
# - PGADMIN_DEFAULT_PASSWORD=admin
|
||||
# depends_on:
|
||||
# - db
|
||||
23
server/jest.config.js
Normal file
23
server/jest.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
moduleFileExtensions: ['ts', 'js'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/index.ts',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', {
|
||||
useESM: true,
|
||||
}],
|
||||
},
|
||||
};
|
||||
4757
server/package-lock.json
generated
Normal file
4757
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
server/package.json
Normal file
43
server/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "fftcg-server",
|
||||
"version": "1.0.0",
|
||||
"description": "FF-TCG Digital game server",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.10.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^6.9.9",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/ws": "^8.5.10",
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^5.10.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
373
server/src/api/routes.ts
Normal file
373
server/src/api/routes.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import {
|
||||
register,
|
||||
login,
|
||||
verifyEmail,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
resendVerification,
|
||||
} from '../auth/AuthService.js';
|
||||
import { requireAuth } from '../auth/JwtMiddleware.js';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// ============ AUTH ROUTES ============
|
||||
|
||||
// Register new account
|
||||
router.post('/auth/register', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password, username } = req.body;
|
||||
|
||||
if (!email || !password || !username) {
|
||||
res.status(400).json({ success: false, message: 'Email, password, and username are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await register(email, password, username);
|
||||
res.status(result.success ? 201 : 400).json(result);
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/auth/login', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ success: false, message: 'Email and password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await login(email, password);
|
||||
res.status(result.success ? 200 : 401).json(result);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify email
|
||||
router.post('/auth/verify-email', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
res.status(400).json({ success: false, message: 'Token is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await verifyEmail(token);
|
||||
res.status(result.success ? 200 : 400).json(result);
|
||||
} catch (error) {
|
||||
console.error('Verify email error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Forgot password
|
||||
router.post('/auth/forgot-password', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
res.status(400).json({ success: false, message: 'Email is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await forgotPassword(email);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Forgot password error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset password
|
||||
router.post('/auth/reset-password', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { token, newPassword } = req.body;
|
||||
|
||||
if (!token || !newPassword) {
|
||||
res.status(400).json({ success: false, message: 'Token and new password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await resetPassword(token, newPassword);
|
||||
res.status(result.success ? 200 : 400).json(result);
|
||||
} catch (error) {
|
||||
console.error('Reset password error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Resend verification email
|
||||
router.post('/auth/resend-verification', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
res.status(400).json({ success: false, message: 'Email is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await resendVerification(email);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Resend verification error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ USER ROUTES ============
|
||||
|
||||
// Get user profile
|
||||
router.get('/user/profile', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.userId },
|
||||
include: {
|
||||
stats: true,
|
||||
decks: {
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, message: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
createdAt: user.createdAt,
|
||||
stats: {
|
||||
wins: user.stats?.wins ?? 0,
|
||||
losses: user.stats?.losses ?? 0,
|
||||
eloRating: user.stats?.eloRating ?? 1000,
|
||||
gamesPlayed: user.stats?.gamesPlayed ?? 0,
|
||||
},
|
||||
decks: user.decks.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
cardIds: d.cardIds,
|
||||
createdAt: d.createdAt,
|
||||
updatedAt: d.updatedAt,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get profile error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get match history
|
||||
router.get('/user/match-history', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const matches = await prisma.match.findMany({
|
||||
where: {
|
||||
OR: [{ player1Id: req.user!.userId }, { player2Id: req.user!.userId }],
|
||||
},
|
||||
include: {
|
||||
player1: { select: { username: true } },
|
||||
player2: { select: { username: true } },
|
||||
winner: { select: { username: true } },
|
||||
},
|
||||
orderBy: { playedAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
matches: matches.map((m) => ({
|
||||
id: m.id,
|
||||
player1: m.player1.username,
|
||||
player2: m.player2.username,
|
||||
winner: m.winner?.username ?? null,
|
||||
result: m.result,
|
||||
turns: m.turns,
|
||||
eloChange: m.eloChange,
|
||||
playedAt: m.playedAt,
|
||||
isWin: m.winnerId === req.user!.userId,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get match history error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ DECK ROUTES ============
|
||||
|
||||
// Save deck
|
||||
router.post('/user/decks', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, cardIds } = req.body;
|
||||
|
||||
if (!name || !cardIds || !Array.isArray(cardIds)) {
|
||||
res.status(400).json({ success: false, message: 'Name and cardIds array are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (cardIds.length !== 50) {
|
||||
res.status(400).json({ success: false, message: 'Deck must contain exactly 50 cards' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check deck limit (max 20 decks per user)
|
||||
const deckCount = await prisma.deck.count({
|
||||
where: { userId: req.user!.userId },
|
||||
});
|
||||
|
||||
if (deckCount >= 20) {
|
||||
res.status(400).json({ success: false, message: 'Maximum deck limit reached (20)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deck = await prisma.deck.create({
|
||||
data: {
|
||||
userId: req.user!.userId,
|
||||
name,
|
||||
cardIds,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
deck: {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
cardIds: deck.cardIds,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Save deck error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update deck
|
||||
router.put('/user/decks/:id', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, cardIds } = req.body;
|
||||
|
||||
// Verify ownership
|
||||
const existingDeck = await prisma.deck.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingDeck || existingDeck.userId !== req.user!.userId) {
|
||||
res.status(404).json({ success: false, message: 'Deck not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (cardIds && cardIds.length !== 50) {
|
||||
res.status(400).json({ success: false, message: 'Deck must contain exactly 50 cards' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deck = await prisma.deck.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name && { name }),
|
||||
...(cardIds && { cardIds }),
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deck: {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
cardIds: deck.cardIds,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update deck error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete deck
|
||||
router.delete('/user/decks/:id', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Verify ownership
|
||||
const deck = await prisma.deck.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!deck || deck.userId !== req.user!.userId) {
|
||||
res.status(404).json({ success: false, message: 'Deck not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.deck.delete({ where: { id } });
|
||||
|
||||
res.json({ success: true, message: 'Deck deleted' });
|
||||
} catch (error) {
|
||||
console.error('Delete deck error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ LEADERBOARD ROUTES ============
|
||||
|
||||
// Get leaderboard
|
||||
router.get('/leaderboard', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const stats = await prisma.playerStats.findMany({
|
||||
where: {
|
||||
gamesPlayed: { gte: 10 }, // Minimum 10 games to appear on leaderboard
|
||||
},
|
||||
include: {
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
orderBy: { eloRating: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
players: stats.map((s, index) => ({
|
||||
rank: offset + index + 1,
|
||||
username: s.user.username,
|
||||
eloRating: s.eloRating,
|
||||
wins: s.wins,
|
||||
losses: s.losses,
|
||||
gamesPlayed: s.gamesPlayed,
|
||||
winRate: s.gamesPlayed > 0 ? Math.round((s.wins / s.gamesPlayed) * 100) : 0,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get leaderboard error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
342
server/src/auth/AuthService.ts
Normal file
342
server/src/auth/AuthService.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { config } from '../config.js';
|
||||
import { sendVerificationEmail, sendPasswordResetEmail } from './EmailService.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Password requirements
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const USERNAME_REGEX = /^[a-zA-Z0-9_-]{3,32}$/;
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
token?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
stats: {
|
||||
wins: number;
|
||||
losses: number;
|
||||
eloRating: number;
|
||||
gamesPlayed: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
function isValidEmail(email: string): boolean {
|
||||
return EMAIL_REGEX.test(email);
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
function isValidUsername(username: string): boolean {
|
||||
return USERNAME_REGEX.test(username);
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
function isValidPassword(password: string): { valid: boolean; message?: string } {
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
return { valid: false, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
function generateToken(user: { id: string; username: string; email: string }): string {
|
||||
const payload: JwtPayload = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
};
|
||||
return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
export function verifyToken(token: string): JwtPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, config.jwtSecret) as JwtPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Register a new user
|
||||
export async function register(
|
||||
email: string,
|
||||
password: string,
|
||||
username: string
|
||||
): Promise<AuthResult> {
|
||||
// Validate inputs
|
||||
if (!isValidEmail(email)) {
|
||||
return { success: false, message: 'Invalid email format' };
|
||||
}
|
||||
|
||||
if (!isValidUsername(username)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Username must be 3-32 characters and contain only letters, numbers, underscores, or hyphens',
|
||||
};
|
||||
}
|
||||
|
||||
const passwordValidation = isValidPassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return { success: false, message: passwordValidation.message! };
|
||||
}
|
||||
|
||||
// Check if email or username already exists
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: email.toLowerCase() }, { username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.email === email.toLowerCase()) {
|
||||
return { success: false, message: 'Email already registered' };
|
||||
}
|
||||
return { success: false, message: 'Username already taken' };
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
// Create user and stats in transaction
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const newUser = await tx.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
passwordHash,
|
||||
username,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.playerStats.create({
|
||||
data: {
|
||||
userId: newUser.id,
|
||||
eloRating: config.elo.startingRating,
|
||||
},
|
||||
});
|
||||
|
||||
return newUser;
|
||||
});
|
||||
|
||||
// Create verification token
|
||||
const verificationToken = uuidv4();
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
token: verificationToken,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
},
|
||||
});
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
await sendVerificationEmail(email, username, verificationToken);
|
||||
} catch (error) {
|
||||
console.error('Failed to send verification email:', error);
|
||||
// Don't fail registration if email fails
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Account created. Please check your email to verify your account.',
|
||||
};
|
||||
}
|
||||
|
||||
// Login user
|
||||
export async function login(email: string, password: string): Promise<AuthResult> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
include: { stats: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { success: false, message: 'Invalid email or password' };
|
||||
}
|
||||
|
||||
const passwordMatch = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!passwordMatch) {
|
||||
return { success: false, message: 'Invalid email or password' };
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
return { success: false, message: 'Please verify your email before logging in' };
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLogin: new Date() },
|
||||
});
|
||||
|
||||
const token = generateToken(user);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
stats: {
|
||||
wins: user.stats?.wins ?? 0,
|
||||
losses: user.stats?.losses ?? 0,
|
||||
eloRating: user.stats?.eloRating ?? config.elo.startingRating,
|
||||
gamesPlayed: user.stats?.gamesPlayed ?? 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Verify email
|
||||
export async function verifyEmail(token: string): Promise<AuthResult> {
|
||||
const verificationToken = await prisma.verificationToken.findUnique({
|
||||
where: { token },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
return { success: false, message: 'Invalid verification token' };
|
||||
}
|
||||
|
||||
if (verificationToken.expiresAt < new Date()) {
|
||||
// Delete expired token
|
||||
await prisma.verificationToken.delete({ where: { token } });
|
||||
return { success: false, message: 'Verification token has expired' };
|
||||
}
|
||||
|
||||
// Update user and delete token
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: verificationToken.userId },
|
||||
data: { emailVerified: true },
|
||||
}),
|
||||
prisma.verificationToken.delete({ where: { token } }),
|
||||
]);
|
||||
|
||||
return { success: true, message: 'Email verified successfully' };
|
||||
}
|
||||
|
||||
// Request password reset
|
||||
export async function forgotPassword(email: string): Promise<AuthResult> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
if (!user) {
|
||||
return { success: true, message: 'If the email exists, a reset link has been sent' };
|
||||
}
|
||||
|
||||
// Delete any existing reset tokens for this user
|
||||
await prisma.passwordResetToken.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
// Create new reset token
|
||||
const resetToken = uuidv4();
|
||||
await prisma.passwordResetToken.create({
|
||||
data: {
|
||||
token: resetToken,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
|
||||
},
|
||||
});
|
||||
|
||||
// Send reset email
|
||||
try {
|
||||
await sendPasswordResetEmail(email, user.username, resetToken);
|
||||
} catch (error) {
|
||||
console.error('Failed to send password reset email:', error);
|
||||
}
|
||||
|
||||
return { success: true, message: 'If the email exists, a reset link has been sent' };
|
||||
}
|
||||
|
||||
// Reset password
|
||||
export async function resetPassword(token: string, newPassword: string): Promise<AuthResult> {
|
||||
const passwordValidation = isValidPassword(newPassword);
|
||||
if (!passwordValidation.valid) {
|
||||
return { success: false, message: passwordValidation.message! };
|
||||
}
|
||||
|
||||
const resetToken = await prisma.passwordResetToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (!resetToken) {
|
||||
return { success: false, message: 'Invalid reset token' };
|
||||
}
|
||||
|
||||
if (resetToken.expiresAt < new Date()) {
|
||||
await prisma.passwordResetToken.delete({ where: { token } });
|
||||
return { success: false, message: 'Reset token has expired' };
|
||||
}
|
||||
|
||||
// Hash new password and update user
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: resetToken.userId },
|
||||
data: { passwordHash },
|
||||
}),
|
||||
prisma.passwordResetToken.delete({ where: { token } }),
|
||||
]);
|
||||
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
// Resend verification email
|
||||
export async function resendVerification(email: string): Promise<AuthResult> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { success: true, message: 'If the email exists, a verification link has been sent' };
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return { success: false, message: 'Email is already verified' };
|
||||
}
|
||||
|
||||
// Delete existing verification tokens
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
// Create new token
|
||||
const verificationToken = uuidv4();
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
token: verificationToken,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await sendVerificationEmail(email, user.username, verificationToken);
|
||||
} catch (error) {
|
||||
console.error('Failed to send verification email:', error);
|
||||
}
|
||||
|
||||
return { success: true, message: 'Verification email sent' };
|
||||
}
|
||||
78
server/src/auth/EmailService.ts
Normal file
78
server/src/auth/EmailService.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { config } from '../config.js';
|
||||
|
||||
// Email transporter instance
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransporter(): nodemailer.Transporter {
|
||||
if (!transporter) {
|
||||
if (config.smtp.user && config.smtp.pass) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.smtp.host,
|
||||
port: config.smtp.port,
|
||||
secure: config.smtp.secure,
|
||||
auth: {
|
||||
user: config.smtp.user,
|
||||
pass: config.smtp.pass,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// In dev mode without SMTP config, use a test account or log emails
|
||||
console.warn('SMTP not configured - emails will be logged to console');
|
||||
transporter = {
|
||||
sendMail: async (options: nodemailer.SendMailOptions) => {
|
||||
console.log('--- EMAIL (dev mode) ---');
|
||||
console.log('To:', options.to);
|
||||
console.log('Subject:', options.subject);
|
||||
console.log('Body:', options.html || options.text);
|
||||
console.log('------------------------');
|
||||
return { messageId: 'dev-mode' };
|
||||
},
|
||||
} as nodemailer.Transporter;
|
||||
}
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const verifyUrl = `${config.appUrl}/verify-email?token=${token}`;
|
||||
|
||||
await getTransporter().sendMail({
|
||||
from: config.smtp.from,
|
||||
to: email,
|
||||
subject: 'Verify your FF-TCG account',
|
||||
html: `
|
||||
<h1>Welcome to FF-TCG Digital, ${username}!</h1>
|
||||
<p>Please verify your email address by clicking the link below:</p>
|
||||
<p><a href="${verifyUrl}">${verifyUrl}</a></p>
|
||||
<p>This link will expire in 24 hours.</p>
|
||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const resetUrl = `${config.appUrl}/reset-password?token=${token}`;
|
||||
|
||||
await getTransporter().sendMail({
|
||||
from: config.smtp.from,
|
||||
to: email,
|
||||
subject: 'Reset your FF-TCG password',
|
||||
html: `
|
||||
<h1>Password Reset Request</h1>
|
||||
<p>Hi ${username},</p>
|
||||
<p>We received a request to reset your password. Click the link below to set a new password:</p>
|
||||
<p><a href="${resetUrl}">${resetUrl}</a></p>
|
||||
<p>This link will expire in 1 hour.</p>
|
||||
<p>If you didn't request a password reset, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
47
server/src/auth/JwtMiddleware.ts
Normal file
47
server/src/auth/JwtMiddleware.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { verifyToken, JwtPayload } from './AuthService.js';
|
||||
|
||||
// Extend Express Request to include user
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: JwtPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware to require authentication
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({ success: false, message: 'Authorization required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
res.status(401).json({ success: false, message: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = payload;
|
||||
next();
|
||||
}
|
||||
|
||||
// Middleware for optional authentication (doesn't fail if no token)
|
||||
export function optionalAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
if (payload) {
|
||||
req.user = payload;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
43
server/src/config.ts
Normal file
43
server/src/config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Server configuration from environment variables
|
||||
|
||||
export const config = {
|
||||
// Server ports
|
||||
httpPort: parseInt(process.env.HTTP_PORT || '3000'),
|
||||
wsPort: parseInt(process.env.WS_PORT || '3001'),
|
||||
|
||||
// Database
|
||||
databaseUrl: process.env.DATABASE_URL || 'postgresql://postgres:password@localhost:5432/fftcg',
|
||||
|
||||
// JWT
|
||||
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||
|
||||
// Email (SMTP)
|
||||
smtp: {
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
user: process.env.SMTP_USER || '',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
from: process.env.SMTP_FROM || 'noreply@fftcg.local',
|
||||
},
|
||||
|
||||
// App URLs
|
||||
appUrl: process.env.APP_URL || 'http://localhost:3000',
|
||||
|
||||
// Game settings
|
||||
turnTimeoutMs: parseInt(process.env.TURN_TIMEOUT_MS || '120000'), // 2 minutes
|
||||
heartbeatIntervalMs: parseInt(process.env.HEARTBEAT_INTERVAL_MS || '10000'), // 10 seconds
|
||||
heartbeatTimeoutMs: parseInt(process.env.HEARTBEAT_TIMEOUT_MS || '30000'), // 30 seconds
|
||||
reconnectWindowMs: parseInt(process.env.RECONNECT_WINDOW_MS || '300000'), // 5 minutes
|
||||
roomCodeExpiryMs: parseInt(process.env.ROOM_CODE_EXPIRY_MS || '3600000'), // 1 hour
|
||||
|
||||
// ELO settings
|
||||
elo: {
|
||||
kFactor: parseInt(process.env.ELO_K_FACTOR || '32'),
|
||||
startingRating: parseInt(process.env.ELO_STARTING_RATING || '1000'),
|
||||
},
|
||||
|
||||
// Environment
|
||||
isDev: process.env.NODE_ENV !== 'production',
|
||||
};
|
||||
144
server/src/db/GameDatabase.ts
Normal file
144
server/src/db/GameDatabase.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// GameDatabase - Database operations for game sessions and player stats
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { GameSessionData } from '../game/GameSession.js';
|
||||
import { EloResult } from '../game/EloCalculator.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Default ELO rating for new players or players without stats
|
||||
const DEFAULT_ELO = 1000;
|
||||
|
||||
/**
|
||||
* Get a player's ELO rating from the database
|
||||
* Returns the player's ELO or default value if not found
|
||||
*/
|
||||
export async function getPlayerElo(userId: string): Promise<number> {
|
||||
try {
|
||||
const stats = await prisma.playerStats.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
return stats?.eloRating ?? DEFAULT_ELO;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ELO for user ${userId}:`, error);
|
||||
return DEFAULT_ELO;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a completed match and update player stats
|
||||
*/
|
||||
export async function recordMatchResult(
|
||||
session: GameSessionData,
|
||||
eloResult: EloResult
|
||||
): Promise<void> {
|
||||
const player1 = session.players[0];
|
||||
const player2 = session.players[1];
|
||||
|
||||
try {
|
||||
// Use a transaction to ensure all updates happen together
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Create the match record
|
||||
await tx.match.create({
|
||||
data: {
|
||||
player1Id: player1.userId,
|
||||
player2Id: player2.userId,
|
||||
winnerId: session.winnerId,
|
||||
player1Deck: player1.deckId,
|
||||
player2Deck: player2.deckId,
|
||||
result: session.endReason,
|
||||
turns: session.turnNumber,
|
||||
eloChange: Math.abs(eloResult.player1Change), // Store absolute change
|
||||
},
|
||||
});
|
||||
|
||||
// Update player 1 stats (upsert in case stats don't exist yet)
|
||||
const player1Won = session.winnerId === player1.userId;
|
||||
await tx.playerStats.upsert({
|
||||
where: { userId: player1.userId },
|
||||
create: {
|
||||
userId: player1.userId,
|
||||
wins: player1Won ? 1 : 0,
|
||||
losses: player1Won ? 0 : 1,
|
||||
eloRating: eloResult.player1NewElo,
|
||||
gamesPlayed: 1,
|
||||
},
|
||||
update: {
|
||||
wins: { increment: player1Won ? 1 : 0 },
|
||||
losses: { increment: player1Won ? 0 : 1 },
|
||||
eloRating: eloResult.player1NewElo,
|
||||
gamesPlayed: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Update player 2 stats (upsert in case stats don't exist yet)
|
||||
const player2Won = session.winnerId === player2.userId;
|
||||
await tx.playerStats.upsert({
|
||||
where: { userId: player2.userId },
|
||||
create: {
|
||||
userId: player2.userId,
|
||||
wins: player2Won ? 1 : 0,
|
||||
losses: player2Won ? 0 : 1,
|
||||
eloRating: eloResult.player2NewElo,
|
||||
gamesPlayed: 1,
|
||||
},
|
||||
update: {
|
||||
wins: { increment: player2Won ? 1 : 0 },
|
||||
losses: { increment: player2Won ? 0 : 1 },
|
||||
eloRating: eloResult.player2NewElo,
|
||||
gamesPlayed: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Update last login timestamps for both players
|
||||
const now = new Date();
|
||||
await tx.user.update({
|
||||
where: { id: player1.userId },
|
||||
data: { lastLogin: now },
|
||||
});
|
||||
await tx.user.update({
|
||||
where: { id: player2.userId },
|
||||
data: { lastLogin: now },
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Match recorded: ${player1.username} vs ${player2.username}, ` +
|
||||
`winner=${session.winnerId === player1.userId ? player1.username : player2.username}, ` +
|
||||
`reason=${session.endReason}`
|
||||
);
|
||||
console.log(
|
||||
`ELO updated: ${player1.username}: ${eloResult.player1NewElo} (${eloResult.player1Change > 0 ? '+' : ''}${eloResult.player1Change}), ` +
|
||||
`${player2.username}: ${eloResult.player2NewElo} (${eloResult.player2Change > 0 ? '+' : ''}${eloResult.player2Change})`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error recording match result:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple player ELO ratings in a single query
|
||||
*/
|
||||
export async function getPlayersElo(userIds: string[]): Promise<Map<string, number>> {
|
||||
const result = new Map<string, number>();
|
||||
|
||||
try {
|
||||
const stats = await prisma.playerStats.findMany({
|
||||
where: { userId: { in: userIds } },
|
||||
select: { userId: true, eloRating: true },
|
||||
});
|
||||
|
||||
// Initialize all with default ELO
|
||||
userIds.forEach(id => result.set(id, DEFAULT_ELO));
|
||||
|
||||
// Override with actual values from database
|
||||
stats.forEach(s => result.set(s.userId, s.eloRating));
|
||||
} catch (error) {
|
||||
console.error('Error fetching player ELOs:', error);
|
||||
// Return defaults on error
|
||||
userIds.forEach(id => result.set(id, DEFAULT_ELO));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
92
server/src/db/schema.prisma
Normal file
92
server/src/db/schema.prisma
Normal file
@@ -0,0 +1,92 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false) @map("email_verified")
|
||||
passwordHash String @map("password_hash")
|
||||
username String @unique
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
lastLogin DateTime? @map("last_login")
|
||||
|
||||
stats PlayerStats?
|
||||
decks Deck[]
|
||||
matchesAsPlayer1 Match[] @relation("Player1Matches")
|
||||
matchesAsPlayer2 Match[] @relation("Player2Matches")
|
||||
matchesWon Match[] @relation("WinnerMatches")
|
||||
verificationTokens VerificationToken[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model PlayerStats {
|
||||
userId String @id @map("user_id")
|
||||
wins Int @default(0)
|
||||
losses Int @default(0)
|
||||
eloRating Int @default(1000) @map("elo_rating")
|
||||
gamesPlayed Int @default(0) @map("games_played")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("player_stats")
|
||||
}
|
||||
|
||||
model Deck {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
name String
|
||||
cardIds Json @map("card_ids")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("decks")
|
||||
}
|
||||
|
||||
model Match {
|
||||
id String @id @default(uuid())
|
||||
player1Id String @map("player1_id")
|
||||
player2Id String @map("player2_id")
|
||||
winnerId String? @map("winner_id")
|
||||
player1Deck Json? @map("player1_deck")
|
||||
player2Deck Json? @map("player2_deck")
|
||||
result String? // 'damage', 'deck_out', 'concede', 'timeout'
|
||||
turns Int?
|
||||
eloChange Int? @map("elo_change")
|
||||
playedAt DateTime @default(now()) @map("played_at")
|
||||
|
||||
player1 User @relation("Player1Matches", fields: [player1Id], references: [id])
|
||||
player2 User @relation("Player2Matches", fields: [player2Id], references: [id])
|
||||
winner User? @relation("WinnerMatches", fields: [winnerId], references: [id])
|
||||
|
||||
@@map("matches")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
token String @id
|
||||
userId String @map("user_id")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
token String @id
|
||||
userId String @map("user_id")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("password_reset_tokens")
|
||||
}
|
||||
66
server/src/game/EloCalculator.ts
Normal file
66
server/src/game/EloCalculator.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// EloCalculator - Standard ELO rating system for competitive play
|
||||
|
||||
export const K_FACTOR = 32; // Standard K-factor for new players
|
||||
export const DEFAULT_ELO = 1000; // Starting rating
|
||||
|
||||
export interface EloResult {
|
||||
player1NewElo: number;
|
||||
player2NewElo: number;
|
||||
player1Change: number;
|
||||
player2Change: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate expected score for a player against an opponent
|
||||
* @param playerElo Player's current ELO rating
|
||||
* @param opponentElo Opponent's current ELO rating
|
||||
* @returns Expected score (0 to 1, where 1 means expected win)
|
||||
*/
|
||||
export function calculateExpectedScore(playerElo: number, opponentElo: number): number {
|
||||
return 1 / (1 + Math.pow(10, (opponentElo - playerElo) / 400));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate ELO rating changes after a match
|
||||
* @param player1Elo Player 1's current ELO rating
|
||||
* @param player2Elo Player 2's current ELO rating
|
||||
* @param player1Won Whether player 1 won the match
|
||||
* @returns New ELO ratings and changes for both players
|
||||
*/
|
||||
export function calculateEloChange(
|
||||
player1Elo: number,
|
||||
player2Elo: number,
|
||||
player1Won: boolean
|
||||
): EloResult {
|
||||
const expected1 = calculateExpectedScore(player1Elo, player2Elo);
|
||||
const expected2 = calculateExpectedScore(player2Elo, player1Elo);
|
||||
|
||||
// Actual score: 1 for win, 0 for loss
|
||||
const actual1 = player1Won ? 1 : 0;
|
||||
const actual2 = player1Won ? 0 : 1;
|
||||
|
||||
// ELO change formula: K * (actual - expected)
|
||||
const change1 = Math.round(K_FACTOR * (actual1 - expected1));
|
||||
const change2 = Math.round(K_FACTOR * (actual2 - expected2));
|
||||
|
||||
return {
|
||||
player1NewElo: Math.max(0, player1Elo + change1), // Prevent negative ELO
|
||||
player2NewElo: Math.max(0, player2Elo + change2),
|
||||
player1Change: change1,
|
||||
player2Change: change2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate ELO change for a specific outcome
|
||||
* Useful for preview calculations
|
||||
*/
|
||||
export function calculatePotentialChange(
|
||||
playerElo: number,
|
||||
opponentElo: number,
|
||||
win: boolean
|
||||
): number {
|
||||
const expected = calculateExpectedScore(playerElo, opponentElo);
|
||||
const actual = win ? 1 : 0;
|
||||
return Math.round(K_FACTOR * (actual - expected));
|
||||
}
|
||||
498
server/src/game/GameSession.ts
Normal file
498
server/src/game/GameSession.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
// GameSession - Represents an active game between two players
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
import { TurnTimer } from './TurnTimer.js';
|
||||
import { calculateEloChange, EloResult } from './EloCalculator.js';
|
||||
import { AuthenticatedSocket } from '../matchmaking/MatchmakingService.js';
|
||||
|
||||
// Game phases matching Godot's Enums.gd
|
||||
export enum TurnPhase {
|
||||
ACTIVE = 0,
|
||||
DRAW = 1,
|
||||
MAIN_1 = 2,
|
||||
ATTACK = 3,
|
||||
MAIN_2 = 4,
|
||||
END = 5,
|
||||
}
|
||||
|
||||
// Attack sub-steps within ATTACK phase
|
||||
export enum AttackStep {
|
||||
NONE = 0,
|
||||
PREPARATION = 1,
|
||||
DECLARATION = 2,
|
||||
BLOCK_DECLARATION = 3,
|
||||
DAMAGE_RESOLUTION = 4,
|
||||
}
|
||||
|
||||
export interface SessionPlayer {
|
||||
socket: AuthenticatedSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
deckId: string;
|
||||
eloRating: number;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface GameSessionData {
|
||||
gameId: string;
|
||||
players: [SessionPlayer, SessionPlayer];
|
||||
currentPlayerIndex: number;
|
||||
currentPhase: TurnPhase;
|
||||
attackStep: AttackStep;
|
||||
turnNumber: number;
|
||||
createdAt: number;
|
||||
startedAt: number | null;
|
||||
endedAt: number | null;
|
||||
winnerId: string | null;
|
||||
endReason: string | null;
|
||||
}
|
||||
|
||||
type GameEndCallback = (session: GameSessionData, eloResult: EloResult) => void;
|
||||
|
||||
const TURN_TIMEOUT_MS = 120000; // 2 minutes
|
||||
const DISCONNECT_TIMEOUT_MS = 60000; // 60 seconds to reconnect
|
||||
|
||||
export class GameSessionInstance {
|
||||
private session: GameSessionData;
|
||||
private turnTimer: TurnTimer;
|
||||
private onGameEnd: GameEndCallback;
|
||||
private disconnectTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
|
||||
constructor(
|
||||
gameId: string,
|
||||
player1: Omit<SessionPlayer, 'connected'>,
|
||||
player2: Omit<SessionPlayer, 'connected'>,
|
||||
firstPlayer: number,
|
||||
onGameEnd: GameEndCallback
|
||||
) {
|
||||
this.onGameEnd = onGameEnd;
|
||||
|
||||
this.session = {
|
||||
gameId,
|
||||
players: [
|
||||
{ ...player1, connected: true },
|
||||
{ ...player2, connected: true },
|
||||
],
|
||||
currentPlayerIndex: firstPlayer,
|
||||
currentPhase: TurnPhase.ACTIVE,
|
||||
attackStep: AttackStep.NONE,
|
||||
turnNumber: 1,
|
||||
createdAt: Date.now(),
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
winnerId: null,
|
||||
endReason: null,
|
||||
};
|
||||
|
||||
this.turnTimer = new TurnTimer(
|
||||
TURN_TIMEOUT_MS,
|
||||
() => this.handleTimeout(),
|
||||
(seconds) => this.broadcastTimer(seconds)
|
||||
);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.session.startedAt = Date.now();
|
||||
this.turnTimer.start();
|
||||
this.broadcastGameStart();
|
||||
}
|
||||
|
||||
getSession(): GameSessionData {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
getGameId(): string {
|
||||
return this.session.gameId;
|
||||
}
|
||||
|
||||
isEnded(): boolean {
|
||||
return this.session.endedAt !== null;
|
||||
}
|
||||
|
||||
// Validate and process incoming action
|
||||
handleAction(
|
||||
userId: string,
|
||||
actionType: string,
|
||||
payload: Record<string, unknown>
|
||||
): { success: boolean; error?: string } {
|
||||
if (this.isEnded()) {
|
||||
return { success: false, error: 'Game has ended' };
|
||||
}
|
||||
|
||||
const playerIndex = this.getPlayerIndex(userId);
|
||||
if (playerIndex === -1) {
|
||||
return { success: false, error: 'Player not in this game' };
|
||||
}
|
||||
|
||||
// Validate turn order (except for blocks - defending player acts)
|
||||
if (actionType !== 'block' && playerIndex !== this.session.currentPlayerIndex) {
|
||||
return { success: false, error: 'Not your turn' };
|
||||
}
|
||||
|
||||
// Validate payload structure
|
||||
const payloadValidation = this.validatePayload(actionType, payload);
|
||||
if (!payloadValidation.valid) {
|
||||
return { success: false, error: payloadValidation.error };
|
||||
}
|
||||
|
||||
// Validate phase and action combination
|
||||
const validation = this.validateActionForPhase(actionType, playerIndex);
|
||||
if (!validation.valid) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
// Action is valid - relay to opponent
|
||||
const opponentIndex = playerIndex === 0 ? 1 : 0;
|
||||
this.sendToPlayer(opponentIndex, 'opponent_action', {
|
||||
action_type: actionType,
|
||||
payload,
|
||||
});
|
||||
|
||||
// Send confirmation to acting player
|
||||
this.sendToPlayer(playerIndex, 'action_confirmed', {
|
||||
action_type: actionType,
|
||||
});
|
||||
|
||||
// Handle phase transitions and side effects
|
||||
this.processActionSideEffects(actionType, playerIndex);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private validatePayload(
|
||||
actionType: string,
|
||||
payload: Record<string, unknown>
|
||||
): { valid: boolean; error?: string } {
|
||||
switch (actionType) {
|
||||
case 'play_card':
|
||||
case 'discard_cp':
|
||||
case 'dull_backup_cp':
|
||||
if (typeof payload.card_instance_id !== 'number') {
|
||||
return { valid: false, error: 'Invalid card_instance_id' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'attack':
|
||||
if (typeof payload.attacker_instance_id !== 'number') {
|
||||
return { valid: false, error: 'Invalid attacker_instance_id' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'block':
|
||||
// blocker_instance_id can be null (no block) or a number
|
||||
if (
|
||||
payload.blocker_instance_id !== null &&
|
||||
typeof payload.blocker_instance_id !== 'number'
|
||||
) {
|
||||
return { valid: false, error: 'Invalid blocker_instance_id' };
|
||||
}
|
||||
break;
|
||||
|
||||
// pass, concede, attack_resolved, report_game_end don't require specific payload validation
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
private validateActionForPhase(
|
||||
actionType: string,
|
||||
playerIndex: number
|
||||
): { valid: boolean; error?: string } {
|
||||
const phase = this.session.currentPhase;
|
||||
const attackStep = this.session.attackStep;
|
||||
const isCurrentPlayer = playerIndex === this.session.currentPlayerIndex;
|
||||
|
||||
switch (actionType) {
|
||||
case 'play_card':
|
||||
case 'discard_cp':
|
||||
case 'dull_backup_cp':
|
||||
if (phase !== TurnPhase.MAIN_1 && phase !== TurnPhase.MAIN_2) {
|
||||
return { valid: false, error: 'Can only play cards during Main phases' };
|
||||
}
|
||||
if (!isCurrentPlayer) {
|
||||
return { valid: false, error: 'Not your turn' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'attack':
|
||||
if (phase !== TurnPhase.ATTACK) {
|
||||
return { valid: false, error: 'Can only attack during Attack phase' };
|
||||
}
|
||||
if (attackStep !== AttackStep.DECLARATION && attackStep !== AttackStep.PREPARATION) {
|
||||
return { valid: false, error: 'Cannot declare attack now' };
|
||||
}
|
||||
if (!isCurrentPlayer) {
|
||||
return { valid: false, error: 'Not your turn' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'block':
|
||||
if (phase !== TurnPhase.ATTACK || attackStep !== AttackStep.BLOCK_DECLARATION) {
|
||||
return { valid: false, error: 'Cannot declare block now' };
|
||||
}
|
||||
// Block is valid from defending player (not current player)
|
||||
if (isCurrentPlayer) {
|
||||
return { valid: false, error: 'Attacker cannot block' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pass':
|
||||
// Pass is generally always valid for current player
|
||||
if (!isCurrentPlayer) {
|
||||
return { valid: false, error: 'Not your turn' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'concede':
|
||||
// Concede is always valid from either player
|
||||
break;
|
||||
|
||||
case 'attack_resolved':
|
||||
if (phase !== TurnPhase.ATTACK || attackStep !== AttackStep.DAMAGE_RESOLUTION) {
|
||||
return { valid: false, error: 'Cannot resolve attack now' };
|
||||
}
|
||||
if (!isCurrentPlayer) {
|
||||
return { valid: false, error: 'Not your turn' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'report_game_end':
|
||||
// Client reports game end (damage/deck_out)
|
||||
// Always valid - server will validate the reason
|
||||
break;
|
||||
|
||||
default:
|
||||
return { valid: false, error: `Unknown action: ${actionType}` };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
private processActionSideEffects(actionType: string, playerIndex: number): void {
|
||||
switch (actionType) {
|
||||
case 'pass':
|
||||
this.advancePhase();
|
||||
break;
|
||||
|
||||
case 'attack':
|
||||
this.session.attackStep = AttackStep.BLOCK_DECLARATION;
|
||||
this.broadcastPhaseChange(); // Notify defender to block
|
||||
break;
|
||||
|
||||
case 'block':
|
||||
this.session.attackStep = AttackStep.DAMAGE_RESOLUTION;
|
||||
// After damage resolution, client will send 'attack_resolved'
|
||||
// which returns to DECLARATION for more attacks
|
||||
break;
|
||||
|
||||
case 'attack_resolved':
|
||||
// Combat resolved, allow more attacks or pass
|
||||
this.session.attackStep = AttackStep.DECLARATION;
|
||||
break;
|
||||
|
||||
case 'concede':
|
||||
const winnerId = this.session.players[playerIndex === 0 ? 1 : 0].userId;
|
||||
this.endGame(winnerId, 'concede');
|
||||
break;
|
||||
|
||||
case 'report_game_end':
|
||||
// Client reports winner - trust client for now
|
||||
// In future, could validate with game state tracking
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Called by client when they detect game over (7 damage or deck out)
|
||||
reportGameEnd(winnerId: string, reason: string): void {
|
||||
if (!this.isEnded()) {
|
||||
this.endGame(winnerId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
private advancePhase(): void {
|
||||
const phases = [
|
||||
TurnPhase.ACTIVE,
|
||||
TurnPhase.DRAW,
|
||||
TurnPhase.MAIN_1,
|
||||
TurnPhase.ATTACK,
|
||||
TurnPhase.MAIN_2,
|
||||
TurnPhase.END,
|
||||
];
|
||||
const currentIndex = phases.indexOf(this.session.currentPhase);
|
||||
|
||||
if (this.session.currentPhase === TurnPhase.END) {
|
||||
// End of turn - switch players
|
||||
this.session.currentPlayerIndex = this.session.currentPlayerIndex === 0 ? 1 : 0;
|
||||
this.session.currentPhase = TurnPhase.ACTIVE;
|
||||
this.session.turnNumber++;
|
||||
this.turnTimer.reset(); // Reset timer for new turn
|
||||
} else {
|
||||
this.session.currentPhase = phases[currentIndex + 1];
|
||||
|
||||
if (this.session.currentPhase === TurnPhase.ATTACK) {
|
||||
this.session.attackStep = AttackStep.PREPARATION;
|
||||
} else {
|
||||
this.session.attackStep = AttackStep.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
this.broadcastPhaseChange();
|
||||
}
|
||||
|
||||
private handleTimeout(): void {
|
||||
// Current player loses due to timeout
|
||||
const winnerId = this.session.players[this.session.currentPlayerIndex === 0 ? 1 : 0].userId;
|
||||
this.endGame(winnerId, 'timeout');
|
||||
}
|
||||
|
||||
private endGame(winnerId: string, reason: string): void {
|
||||
if (this.isEnded()) return;
|
||||
|
||||
this.turnTimer.stop();
|
||||
this.session.endedAt = Date.now();
|
||||
this.session.winnerId = winnerId;
|
||||
this.session.endReason = reason;
|
||||
|
||||
// Clear any pending disconnect timeouts
|
||||
this.disconnectTimeouts.forEach((timeout) => clearTimeout(timeout));
|
||||
this.disconnectTimeouts.clear();
|
||||
|
||||
// Calculate ELO changes
|
||||
const p1Won = winnerId === this.session.players[0].userId;
|
||||
const eloResult = calculateEloChange(
|
||||
this.session.players[0].eloRating,
|
||||
this.session.players[1].eloRating,
|
||||
p1Won
|
||||
);
|
||||
|
||||
// Broadcast game end to both players
|
||||
this.session.players.forEach((player, index) => {
|
||||
const isWinner = player.userId === winnerId;
|
||||
const eloChange = index === 0 ? eloResult.player1Change : eloResult.player2Change;
|
||||
const newElo = index === 0 ? eloResult.player1NewElo : eloResult.player2NewElo;
|
||||
const winnerPlayer = this.session.players.find((p) => p.userId === winnerId);
|
||||
|
||||
this.sendToPlayer(index, 'game_ended', {
|
||||
winner_id: winnerId,
|
||||
winner_username: winnerPlayer?.username || 'Unknown',
|
||||
reason,
|
||||
is_winner: isWinner,
|
||||
elo_change: eloChange,
|
||||
new_elo: newElo,
|
||||
turns: this.session.turnNumber,
|
||||
});
|
||||
});
|
||||
|
||||
this.onGameEnd(this.session, eloResult);
|
||||
}
|
||||
|
||||
handleDisconnect(userId: string): void {
|
||||
const playerIndex = this.getPlayerIndex(userId);
|
||||
if (playerIndex === -1 || this.isEnded()) return;
|
||||
|
||||
this.session.players[playerIndex].connected = false;
|
||||
|
||||
// Notify opponent
|
||||
const opponentIndex = playerIndex === 0 ? 1 : 0;
|
||||
this.sendToPlayer(opponentIndex, 'opponent_disconnected', {
|
||||
reconnect_timeout_seconds: DISCONNECT_TIMEOUT_MS / 1000,
|
||||
});
|
||||
|
||||
// Start disconnect timeout (forfeit after 60s)
|
||||
const timeout = setTimeout(() => {
|
||||
if (!this.session.players[playerIndex].connected && !this.isEnded()) {
|
||||
const winnerId = this.session.players[opponentIndex].userId;
|
||||
this.endGame(winnerId, 'disconnect');
|
||||
}
|
||||
}, DISCONNECT_TIMEOUT_MS);
|
||||
|
||||
this.disconnectTimeouts.set(userId, timeout);
|
||||
}
|
||||
|
||||
handleReconnect(userId: string, socket: AuthenticatedSocket): boolean {
|
||||
const playerIndex = this.getPlayerIndex(userId);
|
||||
if (playerIndex === -1) return false;
|
||||
|
||||
// Clear disconnect timeout
|
||||
const timeout = this.disconnectTimeouts.get(userId);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
this.disconnectTimeouts.delete(userId);
|
||||
}
|
||||
|
||||
// Update socket and connection status
|
||||
this.session.players[playerIndex].socket = socket;
|
||||
this.session.players[playerIndex].connected = true;
|
||||
|
||||
// Send current game state to reconnected player
|
||||
this.sendToPlayer(playerIndex, 'game_state_sync', this.getStateForPlayer(playerIndex));
|
||||
|
||||
// Notify opponent
|
||||
const opponentIndex = playerIndex === 0 ? 1 : 0;
|
||||
this.sendToPlayer(opponentIndex, 'opponent_reconnected', {});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getPlayerIndex(userId: string): number {
|
||||
return this.session.players.findIndex((p) => p.userId === userId);
|
||||
}
|
||||
|
||||
private sendToPlayer(playerIndex: number, type: string, payload: Record<string, unknown>): void {
|
||||
const player = this.session.players[playerIndex];
|
||||
if (player && player.socket.readyState === WebSocket.OPEN) {
|
||||
player.socket.send(JSON.stringify({ type, payload }));
|
||||
}
|
||||
}
|
||||
|
||||
private broadcast(type: string, payload: Record<string, unknown>): void {
|
||||
this.sendToPlayer(0, type, payload);
|
||||
this.sendToPlayer(1, type, payload);
|
||||
}
|
||||
|
||||
private broadcastTimer(seconds: number): void {
|
||||
this.broadcast('turn_timer', { seconds_remaining: seconds });
|
||||
}
|
||||
|
||||
private broadcastPhaseChange(): void {
|
||||
this.broadcast('phase_changed', {
|
||||
phase: this.session.currentPhase,
|
||||
attack_step: this.session.attackStep,
|
||||
current_player_index: this.session.currentPlayerIndex,
|
||||
turn_number: this.session.turnNumber,
|
||||
});
|
||||
}
|
||||
|
||||
private broadcastGameStart(): void {
|
||||
this.session.players.forEach((player, index) => {
|
||||
const opponentIndex = index === 0 ? 1 : 0;
|
||||
this.sendToPlayer(index, 'game_start', {
|
||||
game_id: this.session.gameId,
|
||||
your_player_index: index,
|
||||
opponent: {
|
||||
username: this.session.players[opponentIndex].username,
|
||||
elo: this.session.players[opponentIndex].eloRating,
|
||||
},
|
||||
first_player: this.session.currentPlayerIndex,
|
||||
turn_time_seconds: TURN_TIMEOUT_MS / 1000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getStateForPlayer(playerIndex: number): Record<string, unknown> {
|
||||
const opponentIndex = playerIndex === 0 ? 1 : 0;
|
||||
return {
|
||||
game_id: this.session.gameId,
|
||||
your_player_index: playerIndex,
|
||||
opponent: {
|
||||
username: this.session.players[opponentIndex].username,
|
||||
elo: this.session.players[opponentIndex].eloRating,
|
||||
},
|
||||
current_player_index: this.session.currentPlayerIndex,
|
||||
current_phase: this.session.currentPhase,
|
||||
attack_step: this.session.attackStep,
|
||||
turn_number: this.session.turnNumber,
|
||||
turn_timer_seconds: this.turnTimer.getRemainingSeconds(),
|
||||
};
|
||||
}
|
||||
}
|
||||
159
server/src/game/GameSessionManager.ts
Normal file
159
server/src/game/GameSessionManager.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// GameSessionManager - Manages all active game sessions
|
||||
|
||||
import { GameSessionInstance, GameSessionData, SessionPlayer } from './GameSession.js';
|
||||
import { EloResult } from './EloCalculator.js';
|
||||
import { AuthenticatedSocket } from '../matchmaking/MatchmakingService.js';
|
||||
|
||||
type GameEndHandler = (session: GameSessionData, eloResult: EloResult) => Promise<void>;
|
||||
|
||||
export class GameSessionManager {
|
||||
private sessions: Map<string, GameSessionInstance> = new Map();
|
||||
private userToGame: Map<string, string> = new Map(); // userId -> gameId
|
||||
private onGameEndHandler: GameEndHandler;
|
||||
|
||||
constructor(onGameEnd: GameEndHandler) {
|
||||
this.onGameEndHandler = onGameEnd;
|
||||
console.log('GameSessionManager initialized');
|
||||
}
|
||||
|
||||
createSession(
|
||||
gameId: string,
|
||||
player1: Omit<SessionPlayer, 'connected'>,
|
||||
player2: Omit<SessionPlayer, 'connected'>,
|
||||
firstPlayer: number
|
||||
): GameSessionInstance {
|
||||
// Clean up any existing sessions for these players
|
||||
this.cleanupPlayerSessions(player1.userId);
|
||||
this.cleanupPlayerSessions(player2.userId);
|
||||
|
||||
const session = new GameSessionInstance(
|
||||
gameId,
|
||||
player1,
|
||||
player2,
|
||||
firstPlayer,
|
||||
(endedSession, eloResult) => this.handleGameEnd(endedSession, eloResult)
|
||||
);
|
||||
|
||||
this.sessions.set(gameId, session);
|
||||
this.userToGame.set(player1.userId, gameId);
|
||||
this.userToGame.set(player2.userId, gameId);
|
||||
|
||||
session.start();
|
||||
console.log(
|
||||
`Game session ${gameId} created: ${player1.username} vs ${player2.username}, first player: ${firstPlayer}`
|
||||
);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
handleAction(
|
||||
userId: string,
|
||||
gameId: string,
|
||||
actionType: string,
|
||||
payload: Record<string, unknown>
|
||||
): { success: boolean; error?: string } {
|
||||
const session = this.sessions.get(gameId);
|
||||
if (!session) {
|
||||
return { success: false, error: 'Game session not found' };
|
||||
}
|
||||
|
||||
// Verify user is in this game
|
||||
const userGameId = this.userToGame.get(userId);
|
||||
if (userGameId !== gameId) {
|
||||
return { success: false, error: 'Not in this game' };
|
||||
}
|
||||
|
||||
return session.handleAction(userId, actionType, payload);
|
||||
}
|
||||
|
||||
handleDisconnect(userId: string): void {
|
||||
const gameId = this.userToGame.get(userId);
|
||||
if (!gameId) return;
|
||||
|
||||
const session = this.sessions.get(gameId);
|
||||
if (session && !session.isEnded()) {
|
||||
session.handleDisconnect(userId);
|
||||
}
|
||||
}
|
||||
|
||||
handleReconnect(userId: string, socket: AuthenticatedSocket): boolean {
|
||||
const gameId = this.userToGame.get(userId);
|
||||
if (!gameId) return false;
|
||||
|
||||
const session = this.sessions.get(gameId);
|
||||
if (session && !session.isEnded()) {
|
||||
return session.handleReconnect(userId, socket);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getSessionByGameId(gameId: string): GameSessionInstance | undefined {
|
||||
return this.sessions.get(gameId);
|
||||
}
|
||||
|
||||
getSessionByUserId(userId: string): GameSessionInstance | undefined {
|
||||
const gameId = this.userToGame.get(userId);
|
||||
return gameId ? this.sessions.get(gameId) : undefined;
|
||||
}
|
||||
|
||||
getGameIdByUserId(userId: string): string | undefined {
|
||||
return this.userToGame.get(userId);
|
||||
}
|
||||
|
||||
isInGame(userId: string): boolean {
|
||||
const gameId = this.userToGame.get(userId);
|
||||
if (!gameId) return false;
|
||||
|
||||
const session = this.sessions.get(gameId);
|
||||
return session !== undefined && !session.isEnded();
|
||||
}
|
||||
|
||||
getActiveSessionCount(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
private cleanupPlayerSessions(userId: string): void {
|
||||
const existingGameId = this.userToGame.get(userId);
|
||||
if (existingGameId) {
|
||||
const existingSession = this.sessions.get(existingGameId);
|
||||
if (existingSession && !existingSession.isEnded()) {
|
||||
console.warn(`Player ${userId} already in game ${existingGameId}, cleaning up`);
|
||||
// Force end the existing game as disconnect
|
||||
existingSession.handleDisconnect(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGameEnd(session: GameSessionData, eloResult: EloResult): Promise<void> {
|
||||
// Clean up tracking
|
||||
this.sessions.delete(session.gameId);
|
||||
session.players.forEach((p) => this.userToGame.delete(p.userId));
|
||||
|
||||
console.log(
|
||||
`Game ${session.gameId} ended: winner=${session.winnerId}, reason=${session.endReason}, turns=${session.turnNumber}`
|
||||
);
|
||||
console.log(
|
||||
`ELO changes: ${session.players[0].username}: ${eloResult.player1Change}, ${session.players[1].username}: ${eloResult.player2Change}`
|
||||
);
|
||||
|
||||
// Call external handler (for DB updates)
|
||||
try {
|
||||
await this.onGameEndHandler(session, eloResult);
|
||||
} catch (error) {
|
||||
console.error('Error in game end handler:', error);
|
||||
}
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
// End all active games
|
||||
for (const [gameId, session] of this.sessions.entries()) {
|
||||
if (!session.isEnded()) {
|
||||
console.log(`Shutting down game session ${gameId}`);
|
||||
// Games will be ended without a winner due to server shutdown
|
||||
}
|
||||
}
|
||||
this.sessions.clear();
|
||||
this.userToGame.clear();
|
||||
console.log('GameSessionManager shutdown');
|
||||
}
|
||||
}
|
||||
76
server/src/game/TurnTimer.ts
Normal file
76
server/src/game/TurnTimer.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// TurnTimer - Manages the 2-minute turn timer for online games
|
||||
|
||||
type TimerCallback = () => void;
|
||||
type BroadcastCallback = (secondsRemaining: number) => void;
|
||||
|
||||
export class TurnTimer {
|
||||
private timeoutMs: number;
|
||||
private onTimeout: TimerCallback;
|
||||
private onBroadcast: BroadcastCallback;
|
||||
private startTime: number = 0;
|
||||
private timerId: ReturnType<typeof setTimeout> | null = null;
|
||||
private broadcastInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
timeoutMs: number,
|
||||
onTimeout: TimerCallback,
|
||||
onBroadcast: BroadcastCallback
|
||||
) {
|
||||
this.timeoutMs = timeoutMs; // 120000 for 2 minutes
|
||||
this.onTimeout = onTimeout;
|
||||
this.onBroadcast = onBroadcast;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.stop();
|
||||
this.startTime = Date.now();
|
||||
|
||||
// Main timeout - triggers forfeit when time runs out
|
||||
this.timerId = setTimeout(() => {
|
||||
this.stop();
|
||||
this.onTimeout();
|
||||
}, this.timeoutMs);
|
||||
|
||||
// Broadcast remaining time every second
|
||||
this.broadcastInterval = setInterval(() => {
|
||||
this.onBroadcast(this.getRemainingSeconds());
|
||||
}, 1000);
|
||||
|
||||
// Initial broadcast
|
||||
this.onBroadcast(this.getRemainingSeconds());
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timerId) {
|
||||
clearTimeout(this.timerId);
|
||||
this.timerId = null;
|
||||
}
|
||||
if (this.broadcastInterval) {
|
||||
clearInterval(this.broadcastInterval);
|
||||
this.broadcastInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
// Restart timer with full time (used at start of each turn)
|
||||
this.start();
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
// Stop broadcasting but remember elapsed time
|
||||
// Useful for handling disconnects
|
||||
this.stop();
|
||||
}
|
||||
|
||||
getRemainingSeconds(): number {
|
||||
if (!this.startTime) {
|
||||
return Math.floor(this.timeoutMs / 1000);
|
||||
}
|
||||
const elapsed = Date.now() - this.startTime;
|
||||
return Math.max(0, Math.floor((this.timeoutMs - elapsed) / 1000));
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.timerId !== null;
|
||||
}
|
||||
}
|
||||
152
server/src/game/__tests__/EloCalculator.test.ts
Normal file
152
server/src/game/__tests__/EloCalculator.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
calculateExpectedScore,
|
||||
calculateEloChange,
|
||||
calculatePotentialChange,
|
||||
K_FACTOR,
|
||||
DEFAULT_ELO,
|
||||
} from '../EloCalculator';
|
||||
|
||||
describe('EloCalculator', () => {
|
||||
describe('constants', () => {
|
||||
it('should have K_FACTOR of 32', () => {
|
||||
expect(K_FACTOR).toBe(32);
|
||||
});
|
||||
|
||||
it('should have DEFAULT_ELO of 1000', () => {
|
||||
expect(DEFAULT_ELO).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateExpectedScore', () => {
|
||||
it('should return 0.5 for equal ELO ratings', () => {
|
||||
const expected = calculateExpectedScore(1000, 1000);
|
||||
expect(expected).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should return higher value for higher rated player', () => {
|
||||
const higherRated = calculateExpectedScore(1200, 1000);
|
||||
const lowerRated = calculateExpectedScore(1000, 1200);
|
||||
|
||||
expect(higherRated).toBeGreaterThan(0.5);
|
||||
expect(lowerRated).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it('should return values between 0 and 1', () => {
|
||||
const result1 = calculateExpectedScore(1500, 1000);
|
||||
const result2 = calculateExpectedScore(1000, 1500);
|
||||
|
||||
expect(result1).toBeGreaterThan(0);
|
||||
expect(result1).toBeLessThan(1);
|
||||
expect(result2).toBeGreaterThan(0);
|
||||
expect(result2).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('should return approximately 0.76 for 200 ELO advantage', () => {
|
||||
const expected = calculateExpectedScore(1200, 1000);
|
||||
expect(expected).toBeCloseTo(0.76, 2);
|
||||
});
|
||||
|
||||
it('should return approximately 0.24 for 200 ELO disadvantage', () => {
|
||||
const expected = calculateExpectedScore(1000, 1200);
|
||||
expect(expected).toBeCloseTo(0.24, 2);
|
||||
});
|
||||
|
||||
it('should return sum of 1 for both players', () => {
|
||||
const p1Expected = calculateExpectedScore(1000, 1200);
|
||||
const p2Expected = calculateExpectedScore(1200, 1000);
|
||||
|
||||
expect(p1Expected + p2Expected).toBeCloseTo(1, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEloChange', () => {
|
||||
it('should give positive change to winner', () => {
|
||||
const result = calculateEloChange(1000, 1000, true);
|
||||
expect(result.player1Change).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should give negative change to loser', () => {
|
||||
const result = calculateEloChange(1000, 1000, true);
|
||||
expect(result.player2Change).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should give equal and opposite changes for equal ELO', () => {
|
||||
const result = calculateEloChange(1000, 1000, true);
|
||||
expect(result.player1Change).toBe(-result.player2Change);
|
||||
});
|
||||
|
||||
it('should give +16/-16 for equal ELO match', () => {
|
||||
const result = calculateEloChange(1000, 1000, true);
|
||||
expect(result.player1Change).toBe(16);
|
||||
expect(result.player2Change).toBe(-16);
|
||||
});
|
||||
|
||||
it('should calculate new ELO correctly', () => {
|
||||
const result = calculateEloChange(1000, 1000, true);
|
||||
expect(result.player1NewElo).toBe(1016);
|
||||
expect(result.player2NewElo).toBe(984);
|
||||
});
|
||||
|
||||
it('should give larger gain for upset win (lower beats higher)', () => {
|
||||
const upsetWin = calculateEloChange(1000, 1200, true);
|
||||
const expectedWin = calculateEloChange(1200, 1000, true);
|
||||
|
||||
expect(upsetWin.player1Change).toBeGreaterThan(expectedWin.player1Change);
|
||||
});
|
||||
|
||||
it('should give approximately +24 for upset win (200 ELO difference)', () => {
|
||||
const result = calculateEloChange(1000, 1200, true);
|
||||
expect(result.player1Change).toBeCloseTo(24, 0);
|
||||
});
|
||||
|
||||
it('should give approximately +8 for expected win (200 ELO advantage)', () => {
|
||||
const result = calculateEloChange(1200, 1000, true);
|
||||
expect(result.player1Change).toBeCloseTo(8, 0);
|
||||
});
|
||||
|
||||
it('should prevent negative ELO', () => {
|
||||
const result = calculateEloChange(10, 1500, false);
|
||||
expect(result.player1NewElo).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should handle large ELO differences correctly', () => {
|
||||
const result = calculateEloChange(1000, 2000, true);
|
||||
|
||||
expect(result.player1Change).toBeGreaterThan(28);
|
||||
expect(result.player1Change).toBeLessThanOrEqual(32);
|
||||
});
|
||||
|
||||
it('should round changes to integers', () => {
|
||||
const result = calculateEloChange(1050, 1100, true);
|
||||
|
||||
expect(Number.isInteger(result.player1Change)).toBe(true);
|
||||
expect(Number.isInteger(result.player2Change)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculatePotentialChange', () => {
|
||||
it('should return positive value for win', () => {
|
||||
const change = calculatePotentialChange(1000, 1000, true);
|
||||
expect(change).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return negative value for loss', () => {
|
||||
const change = calculatePotentialChange(1000, 1000, false);
|
||||
expect(change).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should match player1Change from calculateEloChange for win', () => {
|
||||
const potential = calculatePotentialChange(1000, 1200, true);
|
||||
const full = calculateEloChange(1000, 1200, true);
|
||||
|
||||
expect(potential).toBe(full.player1Change);
|
||||
});
|
||||
|
||||
it('should match player1Change from calculateEloChange for loss', () => {
|
||||
const potential = calculatePotentialChange(1000, 1200, false);
|
||||
const full = calculateEloChange(1000, 1200, false);
|
||||
|
||||
expect(potential).toBe(full.player1Change);
|
||||
});
|
||||
});
|
||||
});
|
||||
640
server/src/game/__tests__/GameSession.test.ts
Normal file
640
server/src/game/__tests__/GameSession.test.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
import { WebSocket } from 'ws';
|
||||
import {
|
||||
GameSessionInstance,
|
||||
GameSessionData,
|
||||
TurnPhase,
|
||||
AttackStep,
|
||||
SessionPlayer,
|
||||
} from '../GameSession';
|
||||
import { EloResult } from '../EloCalculator';
|
||||
import { AuthenticatedSocket } from '../../matchmaking/MatchmakingService';
|
||||
|
||||
// Mock WebSocket
|
||||
const createMockSocket = (userId: string): AuthenticatedSocket => {
|
||||
const messages: string[] = [];
|
||||
return {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: jest.fn((data: string) => messages.push(data)),
|
||||
userId,
|
||||
username: `user_${userId}`,
|
||||
_messages: messages,
|
||||
} as unknown as AuthenticatedSocket;
|
||||
};
|
||||
|
||||
const createPlayer = (
|
||||
userId: string,
|
||||
elo: number = 1000
|
||||
): Omit<SessionPlayer, 'connected'> => ({
|
||||
socket: createMockSocket(userId),
|
||||
userId,
|
||||
username: `Player_${userId}`,
|
||||
deckId: `deck_${userId}`,
|
||||
eloRating: elo,
|
||||
});
|
||||
|
||||
describe('GameSessionInstance', () => {
|
||||
let session: GameSessionInstance;
|
||||
let player1: Omit<SessionPlayer, 'connected'>;
|
||||
let player2: Omit<SessionPlayer, 'connected'>;
|
||||
let mockOnGameEnd: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
player1 = createPlayer('user1');
|
||||
player2 = createPlayer('user2');
|
||||
mockOnGameEnd = jest.fn();
|
||||
session = new GameSessionInstance('game1', player1, player2, 0, mockOnGameEnd);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should create session with correct initial state', () => {
|
||||
const state = session.getSession();
|
||||
|
||||
expect(state.gameId).toBe('game1');
|
||||
expect(state.currentPhase).toBe(TurnPhase.ACTIVE);
|
||||
expect(state.attackStep).toBe(AttackStep.NONE);
|
||||
expect(state.turnNumber).toBe(1);
|
||||
expect(state.startedAt).toBeNull();
|
||||
expect(state.endedAt).toBeNull();
|
||||
expect(state.winnerId).toBeNull();
|
||||
});
|
||||
|
||||
it('should set first player correctly', () => {
|
||||
const session0 = new GameSessionInstance('g1', player1, player2, 0, mockOnGameEnd);
|
||||
const session1 = new GameSessionInstance('g2', player1, player2, 1, mockOnGameEnd);
|
||||
|
||||
expect(session0.getSession().currentPlayerIndex).toBe(0);
|
||||
expect(session1.getSession().currentPlayerIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('should initialize both players as connected', () => {
|
||||
const state = session.getSession();
|
||||
|
||||
expect(state.players[0].connected).toBe(true);
|
||||
expect(state.players[1].connected).toBe(true);
|
||||
});
|
||||
|
||||
it('should return correct gameId', () => {
|
||||
expect(session.getGameId()).toBe('game1');
|
||||
});
|
||||
|
||||
it('should not be ended initially', () => {
|
||||
expect(session.isEnded()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('should set startedAt timestamp', () => {
|
||||
session.start();
|
||||
expect(session.getSession().startedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should broadcast game_start to both players', () => {
|
||||
session.start();
|
||||
|
||||
const p1Socket = player1.socket as unknown as { send: jest.Mock };
|
||||
const p2Socket = player2.socket as unknown as { send: jest.Mock };
|
||||
|
||||
expect(p1Socket.send).toHaveBeenCalled();
|
||||
expect(p2Socket.send).toHaveBeenCalled();
|
||||
|
||||
const p1Message = JSON.parse(p1Socket.send.mock.calls[0][0]);
|
||||
expect(p1Message.type).toBe('game_start');
|
||||
expect(p1Message.payload.your_player_index).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action validation - turn order', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
// Advance to MAIN_1 for card actions
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
});
|
||||
|
||||
it('should reject actions from non-participants', () => {
|
||||
const result = session.handleAction('unknown_user', 'pass', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Player not in this game');
|
||||
});
|
||||
|
||||
it('should reject actions when not players turn (except block)', () => {
|
||||
const result = session.handleAction('user2', 'play_card', { card_instance_id: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Not your turn');
|
||||
});
|
||||
|
||||
it('should allow actions from current player', () => {
|
||||
const result = session.handleAction('user1', 'play_card', { card_instance_id: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action validation - phase rules', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
});
|
||||
|
||||
it('should reject play_card outside main phases', () => {
|
||||
// In ACTIVE phase
|
||||
const result = session.handleAction('user1', 'play_card', { card_instance_id: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Can only play cards during Main phases');
|
||||
});
|
||||
|
||||
it('should allow play_card in MAIN_1', () => {
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
|
||||
const result = session.handleAction('user1', 'play_card', { card_instance_id: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow play_card in MAIN_2', () => {
|
||||
// Advance to MAIN_2
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'pass', {}); // ATTACK -> MAIN_2
|
||||
|
||||
const result = session.handleAction('user1', 'play_card', { card_instance_id: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject attack outside attack phase', () => {
|
||||
const result = session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Can only attack during Attack phase');
|
||||
});
|
||||
|
||||
it('should allow attack in attack phase PREPARATION', () => {
|
||||
// Advance to ATTACK phase
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.ATTACK);
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.PREPARATION);
|
||||
|
||||
const result = session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject block outside BLOCK_DECLARATION step', () => {
|
||||
// Advance to ATTACK phase
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
|
||||
const result = session.handleAction('user2', 'block', { blocker_instance_id: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Cannot declare block now');
|
||||
});
|
||||
|
||||
it('should allow block from defending player in BLOCK_DECLARATION', () => {
|
||||
// Advance to ATTACK phase and declare attack
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.BLOCK_DECLARATION);
|
||||
|
||||
const result = session.handleAction('user2', 'block', { blocker_instance_id: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject block from attacking player', () => {
|
||||
// Advance to ATTACK phase and declare attack
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
|
||||
const result = session.handleAction('user1', 'block', { blocker_instance_id: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Attacker cannot block');
|
||||
});
|
||||
|
||||
it('should reject attack_resolved outside DAMAGE_RESOLUTION', () => {
|
||||
// Advance to ATTACK phase
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
|
||||
const result = session.handleAction('user1', 'attack_resolved', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Cannot resolve attack now');
|
||||
});
|
||||
|
||||
it('should allow attack_resolved in DAMAGE_RESOLUTION', () => {
|
||||
// Full attack sequence
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
session.handleAction('user2', 'block', { blocker_instance_id: null });
|
||||
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.DAMAGE_RESOLUTION);
|
||||
|
||||
const result = session.handleAction('user1', 'attack_resolved', {});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow concede from either player at any time', () => {
|
||||
const result = session.handleAction('user2', 'concede', {});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject unknown action types', () => {
|
||||
const result = session.handleAction('user1', 'unknown_action', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Unknown action: unknown_action');
|
||||
});
|
||||
});
|
||||
|
||||
describe('payload validation', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
});
|
||||
|
||||
it('should validate play_card requires card_instance_id', () => {
|
||||
const result = session.handleAction('user1', 'play_card', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid card_instance_id');
|
||||
});
|
||||
|
||||
it('should validate play_card card_instance_id is number', () => {
|
||||
const result = session.handleAction('user1', 'play_card', { card_instance_id: 'invalid' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid card_instance_id');
|
||||
});
|
||||
|
||||
it('should accept valid play_card payload', () => {
|
||||
const result = session.handleAction('user1', 'play_card', { card_instance_id: 123 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate attack requires attacker_instance_id', () => {
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
|
||||
const result = session.handleAction('user1', 'attack', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid attacker_instance_id');
|
||||
});
|
||||
|
||||
it('should allow block with null blocker_instance_id', () => {
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
|
||||
const result = session.handleAction('user2', 'block', { blocker_instance_id: null });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject block with invalid blocker_instance_id', () => {
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
|
||||
const result = session.handleAction('user2', 'block', { blocker_instance_id: 'invalid' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid blocker_instance_id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('phase transitions', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
});
|
||||
|
||||
it('should advance phase on pass action', () => {
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.ACTIVE);
|
||||
|
||||
session.handleAction('user1', 'pass', {});
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.DRAW);
|
||||
|
||||
session.handleAction('user1', 'pass', {});
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.MAIN_1);
|
||||
});
|
||||
|
||||
it('should set attack step to PREPARATION when entering attack phase', () => {
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.ATTACK);
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.PREPARATION);
|
||||
});
|
||||
|
||||
it('should set attack step to NONE when leaving attack phase', () => {
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'pass', {}); // ATTACK -> MAIN_2
|
||||
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.MAIN_2);
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.NONE);
|
||||
});
|
||||
|
||||
it('should switch players at end of turn', () => {
|
||||
// Complete full turn
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'pass', {}); // ATTACK -> MAIN_2
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_2 -> END
|
||||
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.END);
|
||||
expect(session.getSession().currentPlayerIndex).toBe(0);
|
||||
|
||||
session.handleAction('user1', 'pass', {}); // END -> new turn
|
||||
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.ACTIVE);
|
||||
expect(session.getSession().currentPlayerIndex).toBe(1);
|
||||
expect(session.getSession().turnNumber).toBe(2);
|
||||
});
|
||||
|
||||
it('should broadcast phase changes', () => {
|
||||
const p1Socket = player1.socket as unknown as { send: jest.Mock };
|
||||
p1Socket.send.mockClear();
|
||||
|
||||
session.handleAction('user1', 'pass', {});
|
||||
|
||||
const messages = p1Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
const phaseChange = messages.find((m) => m.type === 'phase_changed');
|
||||
|
||||
expect(phaseChange).toBeDefined();
|
||||
expect(phaseChange.payload.phase).toBe(TurnPhase.DRAW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attack flow', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
});
|
||||
|
||||
it('should transition to BLOCK_DECLARATION on attack', () => {
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.BLOCK_DECLARATION);
|
||||
});
|
||||
|
||||
it('should transition to DAMAGE_RESOLUTION on block', () => {
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
session.handleAction('user2', 'block', { blocker_instance_id: 1 });
|
||||
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.DAMAGE_RESOLUTION);
|
||||
});
|
||||
|
||||
it('should return to DECLARATION on attack_resolved', () => {
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
session.handleAction('user2', 'block', { blocker_instance_id: null });
|
||||
session.handleAction('user1', 'attack_resolved', {});
|
||||
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.DECLARATION);
|
||||
});
|
||||
});
|
||||
|
||||
describe('game end', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
});
|
||||
|
||||
it('should end game on concede', () => {
|
||||
session.handleAction('user1', 'concede', {});
|
||||
|
||||
expect(session.isEnded()).toBe(true);
|
||||
expect(session.getSession().winnerId).toBe('user2');
|
||||
expect(session.getSession().endReason).toBe('concede');
|
||||
});
|
||||
|
||||
it('should end game on timeout', () => {
|
||||
jest.advanceTimersByTime(120000);
|
||||
|
||||
expect(session.isEnded()).toBe(true);
|
||||
expect(session.getSession().winnerId).toBe('user2'); // Player 1 timed out
|
||||
expect(session.getSession().endReason).toBe('timeout');
|
||||
});
|
||||
|
||||
it('should calculate ELO correctly', () => {
|
||||
session.handleAction('user1', 'concede', {});
|
||||
|
||||
expect(mockOnGameEnd).toHaveBeenCalled();
|
||||
const eloResult: EloResult = mockOnGameEnd.mock.calls[0][1];
|
||||
|
||||
expect(eloResult.player2Change).toBeGreaterThan(0); // Winner gains
|
||||
expect(eloResult.player1Change).toBeLessThan(0); // Loser loses
|
||||
});
|
||||
|
||||
it('should broadcast game_ended to both players', () => {
|
||||
const p1Socket = player1.socket as unknown as { send: jest.Mock };
|
||||
const p2Socket = player2.socket as unknown as { send: jest.Mock };
|
||||
p1Socket.send.mockClear();
|
||||
p2Socket.send.mockClear();
|
||||
|
||||
session.handleAction('user1', 'concede', {});
|
||||
|
||||
const p1Messages = p1Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
const p2Messages = p2Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
|
||||
const p1End = p1Messages.find((m) => m.type === 'game_ended');
|
||||
const p2End = p2Messages.find((m) => m.type === 'game_ended');
|
||||
|
||||
expect(p1End).toBeDefined();
|
||||
expect(p2End).toBeDefined();
|
||||
expect(p1End.payload.is_winner).toBe(false);
|
||||
expect(p2End.payload.is_winner).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject actions after game ended', () => {
|
||||
session.handleAction('user1', 'concede', {});
|
||||
|
||||
const result = session.handleAction('user2', 'pass', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Game has ended');
|
||||
});
|
||||
|
||||
it('should call onGameEnd callback', () => {
|
||||
session.handleAction('user1', 'concede', {});
|
||||
|
||||
expect(mockOnGameEnd).toHaveBeenCalledTimes(1);
|
||||
const [sessionData, eloResult] = mockOnGameEnd.mock.calls[0];
|
||||
expect(sessionData.gameId).toBe('game1');
|
||||
expect(eloResult).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnect handling', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
});
|
||||
|
||||
it('should mark player as disconnected', () => {
|
||||
session.handleDisconnect('user1');
|
||||
expect(session.getSession().players[0].connected).toBe(false);
|
||||
});
|
||||
|
||||
it('should notify opponent of disconnect', () => {
|
||||
const p2Socket = player2.socket as unknown as { send: jest.Mock };
|
||||
p2Socket.send.mockClear();
|
||||
|
||||
session.handleDisconnect('user1');
|
||||
|
||||
const messages = p2Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
const disconnectMsg = messages.find((m) => m.type === 'opponent_disconnected');
|
||||
|
||||
expect(disconnectMsg).toBeDefined();
|
||||
expect(disconnectMsg.payload.reconnect_timeout_seconds).toBe(60);
|
||||
});
|
||||
|
||||
it('should end game after 60s if not reconnected', () => {
|
||||
session.handleDisconnect('user1');
|
||||
|
||||
jest.advanceTimersByTime(60000);
|
||||
|
||||
expect(session.isEnded()).toBe(true);
|
||||
expect(session.getSession().winnerId).toBe('user2');
|
||||
expect(session.getSession().endReason).toBe('disconnect');
|
||||
});
|
||||
|
||||
it('should not end game if reconnected before timeout', () => {
|
||||
session.handleDisconnect('user1');
|
||||
|
||||
jest.advanceTimersByTime(30000); // 30 seconds
|
||||
|
||||
const newSocket = createMockSocket('user1');
|
||||
session.handleReconnect('user1', newSocket as AuthenticatedSocket);
|
||||
|
||||
jest.advanceTimersByTime(60000); // Past original timeout
|
||||
|
||||
expect(session.isEnded()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconnect handling', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
});
|
||||
|
||||
it('should restore connection on reconnect', () => {
|
||||
session.handleDisconnect('user1');
|
||||
expect(session.getSession().players[0].connected).toBe(false);
|
||||
|
||||
const newSocket = createMockSocket('user1');
|
||||
session.handleReconnect('user1', newSocket as AuthenticatedSocket);
|
||||
|
||||
expect(session.getSession().players[0].connected).toBe(true);
|
||||
});
|
||||
|
||||
it('should sync game state to reconnected player', () => {
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleDisconnect('user1');
|
||||
|
||||
const newSocket = createMockSocket('user1') as unknown as AuthenticatedSocket & { send: jest.Mock };
|
||||
session.handleReconnect('user1', newSocket);
|
||||
|
||||
const messages = newSocket.send.mock.calls.map((call: [string]) => JSON.parse(call[0]));
|
||||
const syncMsg = messages.find((m: { type: string }) => m.type === 'game_state_sync');
|
||||
|
||||
expect(syncMsg).toBeDefined();
|
||||
expect(syncMsg.payload.current_phase).toBe(TurnPhase.DRAW);
|
||||
});
|
||||
|
||||
it('should notify opponent of reconnect', () => {
|
||||
session.handleDisconnect('user1');
|
||||
|
||||
const p2Socket = player2.socket as unknown as { send: jest.Mock };
|
||||
p2Socket.send.mockClear();
|
||||
|
||||
const newSocket = createMockSocket('user1');
|
||||
session.handleReconnect('user1', newSocket as AuthenticatedSocket);
|
||||
|
||||
const messages = p2Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
const reconnectMsg = messages.find((m) => m.type === 'opponent_reconnected');
|
||||
|
||||
expect(reconnectMsg).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return false for unknown user reconnect', () => {
|
||||
const newSocket = createMockSocket('unknown');
|
||||
const result = session.handleReconnect('unknown', newSocket as AuthenticatedSocket);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for successful reconnect', () => {
|
||||
session.handleDisconnect('user1');
|
||||
|
||||
const newSocket = createMockSocket('user1');
|
||||
const result = session.handleReconnect('user1', newSocket as AuthenticatedSocket);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action relay', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
});
|
||||
|
||||
it('should relay action to opponent', () => {
|
||||
const p2Socket = player2.socket as unknown as { send: jest.Mock };
|
||||
p2Socket.send.mockClear();
|
||||
|
||||
session.handleAction('user1', 'play_card', { card_instance_id: 42 });
|
||||
|
||||
const messages = p2Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
const opponentAction = messages.find((m) => m.type === 'opponent_action');
|
||||
|
||||
expect(opponentAction).toBeDefined();
|
||||
expect(opponentAction.payload.action_type).toBe('play_card');
|
||||
expect(opponentAction.payload.payload.card_instance_id).toBe(42);
|
||||
});
|
||||
|
||||
it('should send confirmation to acting player', () => {
|
||||
const p1Socket = player1.socket as unknown as { send: jest.Mock };
|
||||
p1Socket.send.mockClear();
|
||||
|
||||
session.handleAction('user1', 'play_card', { card_instance_id: 42 });
|
||||
|
||||
const messages = p1Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
const confirmation = messages.find((m) => m.type === 'action_confirmed');
|
||||
|
||||
expect(confirmation).toBeDefined();
|
||||
expect(confirmation.payload.action_type).toBe('play_card');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportGameEnd', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
});
|
||||
|
||||
it('should end game with provided winner and reason', () => {
|
||||
session.reportGameEnd('user1', 'damage');
|
||||
|
||||
expect(session.isEnded()).toBe(true);
|
||||
expect(session.getSession().winnerId).toBe('user1');
|
||||
expect(session.getSession().endReason).toBe('damage');
|
||||
});
|
||||
|
||||
it('should not end game if already ended', () => {
|
||||
session.handleAction('user1', 'concede', {});
|
||||
mockOnGameEnd.mockClear();
|
||||
|
||||
session.reportGameEnd('user2', 'deck_out');
|
||||
|
||||
expect(mockOnGameEnd).not.toHaveBeenCalled();
|
||||
expect(session.getSession().endReason).toBe('concede'); // Original reason preserved
|
||||
});
|
||||
});
|
||||
});
|
||||
335
server/src/game/__tests__/GameSessionManager.test.ts
Normal file
335
server/src/game/__tests__/GameSessionManager.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { WebSocket } from 'ws';
|
||||
import { GameSessionManager } from '../GameSessionManager';
|
||||
import { GameSessionData, SessionPlayer, TurnPhase } from '../GameSession';
|
||||
import { EloResult } from '../EloCalculator';
|
||||
import { AuthenticatedSocket } from '../../matchmaking/MatchmakingService';
|
||||
|
||||
// Mock WebSocket
|
||||
const createMockSocket = (userId: string): AuthenticatedSocket => {
|
||||
return {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: jest.fn(),
|
||||
userId,
|
||||
username: `user_${userId}`,
|
||||
} as unknown as AuthenticatedSocket;
|
||||
};
|
||||
|
||||
const createPlayer = (
|
||||
userId: string,
|
||||
elo: number = 1000
|
||||
): Omit<SessionPlayer, 'connected'> => ({
|
||||
socket: createMockSocket(userId),
|
||||
userId,
|
||||
username: `Player_${userId}`,
|
||||
deckId: `deck_${userId}`,
|
||||
eloRating: elo,
|
||||
});
|
||||
|
||||
describe('GameSessionManager', () => {
|
||||
let manager: GameSessionManager;
|
||||
let mockOnGameEnd: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockOnGameEnd = jest.fn().mockResolvedValue(undefined);
|
||||
manager = new GameSessionManager(mockOnGameEnd);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.shutdown();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create session and track by game ID', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
|
||||
const session = manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session.getGameId()).toBe('game1');
|
||||
expect(manager.getSessionByGameId('game1')).toBe(session);
|
||||
});
|
||||
|
||||
it('should track user to game mapping', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(manager.getGameIdByUserId('user1')).toBe('game1');
|
||||
expect(manager.getGameIdByUserId('user2')).toBe('game1');
|
||||
});
|
||||
|
||||
it('should start the session automatically', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
|
||||
const session = manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(session.getSession().startedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should clean up existing sessions for players', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
const player3 = createPlayer('user3');
|
||||
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
expect(manager.isInGame('user1')).toBe(true);
|
||||
|
||||
// Create new session with user1 (should cleanup game1)
|
||||
const newPlayer1 = createPlayer('user1');
|
||||
manager.createSession('game2', newPlayer1, player3, 0);
|
||||
|
||||
expect(manager.getGameIdByUserId('user1')).toBe('game2');
|
||||
});
|
||||
|
||||
it('should increment active session count', () => {
|
||||
expect(manager.getActiveSessionCount()).toBe(0);
|
||||
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(manager.getActiveSessionCount()).toBe(1);
|
||||
|
||||
const player3 = createPlayer('user3');
|
||||
const player4 = createPlayer('user4');
|
||||
manager.createSession('game2', player3, player4, 0);
|
||||
|
||||
expect(manager.getActiveSessionCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAction', () => {
|
||||
let player1: Omit<SessionPlayer, 'connected'>;
|
||||
let player2: Omit<SessionPlayer, 'connected'>;
|
||||
|
||||
beforeEach(() => {
|
||||
player1 = createPlayer('user1');
|
||||
player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
});
|
||||
|
||||
it('should route actions to correct session', () => {
|
||||
const result = manager.handleAction('user1', 'game1', 'pass', {});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should return error for invalid game ID', () => {
|
||||
const result = manager.handleAction('user1', 'nonexistent', 'pass', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Game session not found');
|
||||
});
|
||||
|
||||
it('should return error if user not in game', () => {
|
||||
const player3 = createPlayer('user3');
|
||||
const player4 = createPlayer('user4');
|
||||
manager.createSession('game2', player3, player4, 0);
|
||||
|
||||
// User1 trying to act in game2
|
||||
const result = manager.handleAction('user1', 'game2', 'pass', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Not in this game');
|
||||
});
|
||||
|
||||
it('should forward action to session and return result', () => {
|
||||
// First pass is valid
|
||||
const result1 = manager.handleAction('user1', 'game1', 'pass', {});
|
||||
expect(result1.success).toBe(true);
|
||||
|
||||
// User2 trying to pass (not their turn)
|
||||
const result2 = manager.handleAction('user2', 'game1', 'pass', {});
|
||||
expect(result2.success).toBe(false);
|
||||
expect(result2.error).toBe('Not your turn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDisconnect', () => {
|
||||
it('should forward disconnect to session', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
manager.handleDisconnect('user1');
|
||||
|
||||
const session = manager.getSessionByGameId('game1');
|
||||
expect(session?.getSession().players[0].connected).toBe(false);
|
||||
});
|
||||
|
||||
it('should do nothing for unknown user', () => {
|
||||
expect(() => manager.handleDisconnect('unknown')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should end game after disconnect timeout', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
manager.handleDisconnect('user1');
|
||||
jest.advanceTimersByTime(60000);
|
||||
|
||||
const session = manager.getSessionByGameId('game1');
|
||||
expect(session).toBeUndefined(); // Session cleaned up after end
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleReconnect', () => {
|
||||
it('should forward reconnect to session', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
manager.handleDisconnect('user1');
|
||||
|
||||
const newSocket = createMockSocket('user1');
|
||||
const result = manager.handleReconnect('user1', newSocket);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const session = manager.getSessionByGameId('game1');
|
||||
expect(session?.getSession().players[0].connected).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unknown user', () => {
|
||||
const newSocket = createMockSocket('unknown');
|
||||
const result = manager.handleReconnect('unknown', newSocket);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if session ended', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
// End game via concede
|
||||
manager.handleAction('user1', 'game1', 'concede', {});
|
||||
|
||||
const newSocket = createMockSocket('user1');
|
||||
const result = manager.handleReconnect('user1', newSocket);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionByUserId', () => {
|
||||
it('should return session for user in game', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
const session = manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(manager.getSessionByUserId('user1')).toBe(session);
|
||||
expect(manager.getSessionByUserId('user2')).toBe(session);
|
||||
});
|
||||
|
||||
it('should return undefined for user not in game', () => {
|
||||
expect(manager.getSessionByUserId('unknown')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInGame', () => {
|
||||
it('should return true for user in active game', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(manager.isInGame('user1')).toBe(true);
|
||||
expect(manager.isInGame('user2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for user not in game', () => {
|
||||
expect(manager.isInGame('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false after game ends', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
manager.handleAction('user1', 'game1', 'concede', {});
|
||||
|
||||
expect(manager.isInGame('user1')).toBe(false);
|
||||
expect(manager.isInGame('user2')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('game end handling', () => {
|
||||
it('should clean up mappings on game end', async () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(manager.getActiveSessionCount()).toBe(1);
|
||||
|
||||
manager.handleAction('user1', 'game1', 'concede', {});
|
||||
|
||||
// Allow async handler to complete
|
||||
await Promise.resolve();
|
||||
|
||||
expect(manager.getSessionByGameId('game1')).toBeUndefined();
|
||||
expect(manager.getGameIdByUserId('user1')).toBeUndefined();
|
||||
expect(manager.getGameIdByUserId('user2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call onGameEnd handler', async () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
manager.handleAction('user1', 'game1', 'concede', {});
|
||||
|
||||
// Allow async handler to complete
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockOnGameEnd).toHaveBeenCalledTimes(1);
|
||||
const [sessionData, eloResult] = mockOnGameEnd.mock.calls[0];
|
||||
expect(sessionData.gameId).toBe('game1');
|
||||
expect(sessionData.winnerId).toBe('user2');
|
||||
expect(eloResult).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle errors in onGameEnd handler gracefully', async () => {
|
||||
mockOnGameEnd.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
// Should not throw
|
||||
manager.handleAction('user1', 'game1', 'concede', {});
|
||||
await Promise.resolve();
|
||||
|
||||
// Session should still be cleaned up
|
||||
expect(manager.getSessionByGameId('game1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('should clear all sessions', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
const player3 = createPlayer('user3');
|
||||
const player4 = createPlayer('user4');
|
||||
manager.createSession('game2', player3, player4, 0);
|
||||
|
||||
expect(manager.getActiveSessionCount()).toBe(2);
|
||||
|
||||
manager.shutdown();
|
||||
|
||||
expect(manager.getActiveSessionCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear user mappings', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
manager.shutdown();
|
||||
|
||||
expect(manager.getGameIdByUserId('user1')).toBeUndefined();
|
||||
expect(manager.getGameIdByUserId('user2')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
249
server/src/game/__tests__/TurnTimer.test.ts
Normal file
249
server/src/game/__tests__/TurnTimer.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { TurnTimer } from '../TurnTimer';
|
||||
|
||||
describe('TurnTimer', () => {
|
||||
let timer: TurnTimer;
|
||||
let mockTimeout: jest.Mock;
|
||||
let mockBroadcast: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockTimeout = jest.fn();
|
||||
mockBroadcast = jest.fn();
|
||||
timer = new TurnTimer(120000, mockTimeout, mockBroadcast); // 2 minutes
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
timer.stop();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with correct timeout duration', () => {
|
||||
expect(timer.getRemainingSeconds()).toBe(120);
|
||||
});
|
||||
|
||||
it('should not be running initially', () => {
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('should mark timer as running', () => {
|
||||
timer.start();
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('should broadcast initial time immediately', () => {
|
||||
timer.start();
|
||||
expect(mockBroadcast).toHaveBeenCalledWith(120);
|
||||
});
|
||||
|
||||
it('should broadcast every second when started', () => {
|
||||
timer.start();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockBroadcast).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockBroadcast).toHaveBeenCalledTimes(2);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockBroadcast).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should broadcast decreasing seconds', () => {
|
||||
timer.start();
|
||||
expect(mockBroadcast).toHaveBeenLastCalledWith(120);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockBroadcast).toHaveBeenLastCalledWith(119);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockBroadcast).toHaveBeenLastCalledWith(118);
|
||||
});
|
||||
|
||||
it('should call onTimeout when timer expires', () => {
|
||||
timer.start();
|
||||
expect(mockTimeout).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(120000);
|
||||
expect(mockTimeout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop timer after timeout', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(120000);
|
||||
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not broadcast after timeout', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(120000);
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(mockBroadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop', () => {
|
||||
it('should stop the timer', () => {
|
||||
timer.start();
|
||||
timer.stop();
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should stop broadcasting when stopped', () => {
|
||||
timer.start();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
timer.stop();
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
expect(mockBroadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call timeout if stopped before expiry', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(60000); // Half way
|
||||
|
||||
timer.stop();
|
||||
jest.advanceTimersByTime(120000); // Past original expiry
|
||||
|
||||
expect(mockTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should be safe to call stop when not running', () => {
|
||||
expect(() => timer.stop()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should be safe to call stop multiple times', () => {
|
||||
timer.start();
|
||||
timer.stop();
|
||||
expect(() => timer.stop()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset to full time', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(60000); // 60 seconds elapsed
|
||||
expect(timer.getRemainingSeconds()).toBe(60);
|
||||
|
||||
timer.reset();
|
||||
expect(timer.getRemainingSeconds()).toBe(120);
|
||||
});
|
||||
|
||||
it('should restart broadcasting', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(60000);
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
timer.reset();
|
||||
expect(mockBroadcast).toHaveBeenCalledWith(120);
|
||||
});
|
||||
|
||||
it('should reset timeout timer', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(60000); // 60 seconds elapsed
|
||||
|
||||
timer.reset();
|
||||
jest.advanceTimersByTime(60000); // 60 more seconds
|
||||
|
||||
expect(mockTimeout).not.toHaveBeenCalled(); // Should have 60 seconds left
|
||||
|
||||
jest.advanceTimersByTime(60000); // Full 120 seconds from reset
|
||||
expect(mockTimeout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pause', () => {
|
||||
it('should stop the timer', () => {
|
||||
timer.start();
|
||||
timer.pause();
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should stop broadcasting', () => {
|
||||
timer.start();
|
||||
timer.pause();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(mockBroadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemainingSeconds', () => {
|
||||
it('should return full time before start', () => {
|
||||
expect(timer.getRemainingSeconds()).toBe(120);
|
||||
});
|
||||
|
||||
it('should return correct remaining time', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(30000);
|
||||
expect(timer.getRemainingSeconds()).toBe(90);
|
||||
});
|
||||
|
||||
it('should not return negative values', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(150000); // Beyond timeout
|
||||
expect(timer.getRemainingSeconds()).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return floor of remaining seconds', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(500); // Half second
|
||||
expect(timer.getRemainingSeconds()).toBe(119);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRunning', () => {
|
||||
it('should return false before start', () => {
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true after start', () => {
|
||||
timer.start();
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false after stop', () => {
|
||||
timer.start();
|
||||
timer.stop();
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true after reset', () => {
|
||||
timer.start();
|
||||
timer.stop();
|
||||
timer.reset();
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('different timeout durations', () => {
|
||||
it('should work with shorter timeouts', () => {
|
||||
const shortTimer = new TurnTimer(5000, mockTimeout, mockBroadcast);
|
||||
shortTimer.start();
|
||||
|
||||
expect(shortTimer.getRemainingSeconds()).toBe(5);
|
||||
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(mockTimeout).toHaveBeenCalled();
|
||||
|
||||
shortTimer.stop();
|
||||
});
|
||||
|
||||
it('should work with longer timeouts', () => {
|
||||
const longTimer = new TurnTimer(300000, mockTimeout, mockBroadcast);
|
||||
longTimer.start();
|
||||
|
||||
expect(longTimer.getRemainingSeconds()).toBe(300);
|
||||
|
||||
longTimer.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
439
server/src/index.ts
Normal file
439
server/src/index.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { config } from './config.js';
|
||||
import apiRoutes from './api/routes.js';
|
||||
import { verifyToken } from './auth/AuthService.js';
|
||||
import { MatchmakingService, AuthenticatedSocket, MatchResult } from './matchmaking/MatchmakingService.js';
|
||||
import { RoomManager, Room } from './matchmaking/RoomManager.js';
|
||||
import { GameSessionManager } from './game/GameSessionManager.js';
|
||||
import { GameSessionData } from './game/GameSession.js';
|
||||
import { EloResult } from './game/EloCalculator.js';
|
||||
import { getPlayerElo, recordMatchResult } from './db/GameDatabase.js';
|
||||
|
||||
// ============ EXPRESS SERVER (REST API) ============
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// API routes
|
||||
app.use('/api', apiRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Start HTTP server
|
||||
app.listen(config.httpPort, () => {
|
||||
console.log(`HTTP server listening on port ${config.httpPort}`);
|
||||
});
|
||||
|
||||
// ============ WEBSOCKET SERVER ============
|
||||
|
||||
const wss = new WebSocketServer({ port: config.wsPort });
|
||||
|
||||
// Track connected users
|
||||
const connectedUsers = new Map<string, AuthenticatedSocket>();
|
||||
|
||||
// ============ GAME SESSION MANAGER ============
|
||||
|
||||
// Callback when a game ends (for DB updates)
|
||||
async function onGameEnd(session: GameSessionData, eloResult: EloResult): Promise<void> {
|
||||
try {
|
||||
await recordMatchResult(session, eloResult);
|
||||
} catch (error) {
|
||||
console.error('Failed to record match result:', error);
|
||||
// Log the match details even if DB fails so we don't lose the data
|
||||
console.log(
|
||||
`[FALLBACK] Match: ${session.players[0].username} vs ${session.players[1].username}, ` +
|
||||
`winner=${session.winnerId}, reason=${session.endReason}, ` +
|
||||
`ELO: P1=${eloResult.player1NewElo}, P2=${eloResult.player2NewElo}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const gameSessionManager = new GameSessionManager(onGameEnd);
|
||||
|
||||
// ============ MATCHMAKING SERVICES ============
|
||||
|
||||
// Callback when a match is found (from queue or room)
|
||||
function onMatchFound(match: MatchResult): void {
|
||||
const gameId = crypto.randomUUID();
|
||||
|
||||
console.log(`Creating game ${gameId}: ${match.player1.username} vs ${match.player2.username}`);
|
||||
|
||||
// Create the game session (this sends game_start to both players)
|
||||
gameSessionManager.createSession(
|
||||
gameId,
|
||||
{
|
||||
socket: match.player1.socket,
|
||||
userId: match.player1.userId,
|
||||
username: match.player1.username,
|
||||
deckId: match.player1.deckId,
|
||||
eloRating: match.player1.eloRating,
|
||||
},
|
||||
{
|
||||
socket: match.player2.socket,
|
||||
userId: match.player2.userId,
|
||||
username: match.player2.username,
|
||||
deckId: match.player2.deckId,
|
||||
eloRating: match.player2.eloRating,
|
||||
},
|
||||
match.firstPlayer
|
||||
);
|
||||
}
|
||||
|
||||
// Callback when room game starts
|
||||
function onRoomGameStart(room: Room): void {
|
||||
if (!room.guest) return;
|
||||
|
||||
const match: MatchResult = {
|
||||
player1: room.host,
|
||||
player2: room.guest,
|
||||
firstPlayer: Math.random() < 0.5 ? 0 : 1,
|
||||
};
|
||||
|
||||
onMatchFound(match);
|
||||
}
|
||||
|
||||
// Initialize matchmaking services
|
||||
const matchmakingService = new MatchmakingService(onMatchFound);
|
||||
const roomManager = new RoomManager(onRoomGameStart);
|
||||
|
||||
// ============ HEARTBEAT ============
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
wss.clients.forEach((ws) => {
|
||||
const socket = ws as AuthenticatedSocket;
|
||||
if (socket.isAlive === false) {
|
||||
console.log(`Terminating inactive connection: ${socket.userId}`);
|
||||
if (socket.userId) {
|
||||
connectedUsers.delete(socket.userId);
|
||||
matchmakingService.handleDisconnect(socket.userId);
|
||||
roomManager.handleDisconnect(socket.userId);
|
||||
gameSessionManager.handleDisconnect(socket.userId);
|
||||
}
|
||||
return socket.terminate();
|
||||
}
|
||||
socket.isAlive = false;
|
||||
socket.ping();
|
||||
});
|
||||
}, config.heartbeatIntervalMs);
|
||||
|
||||
wss.on('close', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
matchmakingService.shutdown();
|
||||
roomManager.shutdown();
|
||||
gameSessionManager.shutdown();
|
||||
});
|
||||
|
||||
// ============ CONNECTION HANDLING ============
|
||||
|
||||
wss.on('connection', (ws: AuthenticatedSocket) => {
|
||||
ws.isAlive = true;
|
||||
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true;
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
handleMessage(ws, message);
|
||||
} catch (error) {
|
||||
console.error('Invalid message format:', error);
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Invalid message format' } }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (ws.userId) {
|
||||
console.log(`User disconnected: ${ws.username} (${ws.userId})`);
|
||||
connectedUsers.delete(ws.userId);
|
||||
matchmakingService.handleDisconnect(ws.userId);
|
||||
roomManager.handleDisconnect(ws.userId);
|
||||
gameSessionManager.handleDisconnect(ws.userId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ MESSAGE HANDLING ============
|
||||
|
||||
interface MessagePayload {
|
||||
token?: string;
|
||||
deck_id?: string;
|
||||
room_code?: string;
|
||||
ready?: boolean;
|
||||
game_id?: string;
|
||||
card_instance_id?: number;
|
||||
attacker_instance_id?: number;
|
||||
blocker_instance_id?: number | null;
|
||||
winner_id?: string;
|
||||
reason?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function handleMessage(ws: AuthenticatedSocket, message: { type: string; payload: MessagePayload }) {
|
||||
const { type, payload } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'auth':
|
||||
handleAuth(ws, payload as { token: string });
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
ws.send(JSON.stringify({ type: 'pong', payload: { serverTime: Date.now() } }));
|
||||
break;
|
||||
|
||||
// ========== MATCHMAKING MESSAGES ==========
|
||||
|
||||
case 'queue_join':
|
||||
if (!requireAuth(ws)) return;
|
||||
handleQueueJoin(ws, payload);
|
||||
break;
|
||||
|
||||
case 'queue_leave':
|
||||
if (!requireAuth(ws)) return;
|
||||
matchmakingService.leaveQueue(ws.userId!);
|
||||
break;
|
||||
|
||||
case 'room_create':
|
||||
if (!requireAuth(ws)) return;
|
||||
handleRoomCreate(ws, payload);
|
||||
break;
|
||||
|
||||
case 'room_join':
|
||||
if (!requireAuth(ws)) return;
|
||||
handleRoomJoin(ws, payload);
|
||||
break;
|
||||
|
||||
case 'room_leave':
|
||||
if (!requireAuth(ws)) return;
|
||||
roomManager.leaveRoom(ws.userId!);
|
||||
break;
|
||||
|
||||
case 'room_ready':
|
||||
if (!requireAuth(ws)) return;
|
||||
handleRoomReady(ws, payload);
|
||||
break;
|
||||
|
||||
// ========== GAME ACTION MESSAGES ==========
|
||||
|
||||
case 'action_play_card':
|
||||
case 'action_attack':
|
||||
case 'action_block':
|
||||
case 'action_pass':
|
||||
case 'action_concede':
|
||||
case 'action_discard_cp':
|
||||
case 'action_dull_backup_cp':
|
||||
case 'action_attack_resolved':
|
||||
case 'action_report_game_end':
|
||||
if (!requireAuth(ws)) return;
|
||||
handleGameAction(ws, type, payload);
|
||||
break;
|
||||
|
||||
default:
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: `Unknown message type: ${type}` } }));
|
||||
}
|
||||
}
|
||||
|
||||
// ========== AUTH HANDLERS ==========
|
||||
|
||||
function requireAuth(ws: AuthenticatedSocket): boolean {
|
||||
if (!ws.userId) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Authentication required' } }));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleAuth(ws: AuthenticatedSocket, payload: { token: string }) {
|
||||
const { token } = payload;
|
||||
|
||||
if (!token) {
|
||||
ws.send(JSON.stringify({ type: 'auth_error', payload: { message: 'Token required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
if (!decoded) {
|
||||
ws.send(JSON.stringify({ type: 'auth_error', payload: { message: 'Invalid or expired token' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is already connected
|
||||
const existingSocket = connectedUsers.get(decoded.userId);
|
||||
if (existingSocket) {
|
||||
// Clean up old connection's state
|
||||
matchmakingService.handleDisconnect(decoded.userId);
|
||||
roomManager.handleDisconnect(decoded.userId);
|
||||
|
||||
// Disconnect old socket
|
||||
existingSocket.send(
|
||||
JSON.stringify({ type: 'disconnected', payload: { message: 'Connected from another location' } })
|
||||
);
|
||||
existingSocket.close();
|
||||
}
|
||||
|
||||
// Check if user was in an active game (reconnection)
|
||||
if (gameSessionManager.isInGame(decoded.userId)) {
|
||||
console.log(`User ${decoded.username} reconnecting to active game`);
|
||||
gameSessionManager.handleReconnect(decoded.userId, ws);
|
||||
}
|
||||
|
||||
// Set user info on socket
|
||||
ws.userId = decoded.userId;
|
||||
ws.username = decoded.username;
|
||||
connectedUsers.set(decoded.userId, ws);
|
||||
|
||||
console.log(`User authenticated: ${decoded.username} (${decoded.userId})`);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'auth_success',
|
||||
payload: {
|
||||
userId: decoded.userId,
|
||||
username: decoded.username,
|
||||
inGame: gameSessionManager.isInGame(decoded.userId),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ========== MATCHMAKING HANDLERS ==========
|
||||
|
||||
async function handleQueueJoin(ws: AuthenticatedSocket, payload: MessagePayload) {
|
||||
const { deck_id } = payload;
|
||||
|
||||
if (!deck_id) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Deck selection required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't join queue if already in a game
|
||||
if (gameSessionManager.isInGame(ws.userId!)) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Already in a game' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't be in queue and room at the same time
|
||||
if (roomManager.isInRoom(ws.userId!)) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Leave room before joining queue' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch actual ELO from database
|
||||
const eloRating = await getPlayerElo(ws.userId!);
|
||||
|
||||
matchmakingService.joinQueue(ws, ws.userId!, ws.username!, deck_id, eloRating);
|
||||
}
|
||||
|
||||
async function handleRoomCreate(ws: AuthenticatedSocket, payload: MessagePayload) {
|
||||
const { deck_id } = payload;
|
||||
|
||||
if (!deck_id) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Deck selection required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't create room if already in a game
|
||||
if (gameSessionManager.isInGame(ws.userId!)) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Already in a game' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't be in room and queue at the same time
|
||||
if (matchmakingService.isInQueue(ws.userId!)) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Leave queue before creating room' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch actual ELO from database
|
||||
const eloRating = await getPlayerElo(ws.userId!);
|
||||
|
||||
roomManager.createRoom(ws, ws.userId!, ws.username!, deck_id, eloRating);
|
||||
}
|
||||
|
||||
async function handleRoomJoin(ws: AuthenticatedSocket, payload: MessagePayload) {
|
||||
const { room_code, deck_id } = payload;
|
||||
|
||||
if (!room_code) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Room code required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deck_id) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Deck selection required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't join room if already in a game
|
||||
if (gameSessionManager.isInGame(ws.userId!)) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Already in a game' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't be in room and queue at the same time
|
||||
if (matchmakingService.isInQueue(ws.userId!)) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Leave queue before joining room' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch actual ELO from database
|
||||
const eloRating = await getPlayerElo(ws.userId!);
|
||||
|
||||
roomManager.joinRoom(room_code, ws, ws.userId!, ws.username!, deck_id, eloRating);
|
||||
}
|
||||
|
||||
function handleRoomReady(ws: AuthenticatedSocket, payload: MessagePayload) {
|
||||
const { ready } = payload;
|
||||
|
||||
if (typeof ready !== 'boolean') {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Ready status required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
roomManager.setReady(ws.userId!, ready);
|
||||
}
|
||||
|
||||
// ========== GAME ACTION HANDLERS ==========
|
||||
|
||||
function handleGameAction(ws: AuthenticatedSocket, type: string, payload: MessagePayload) {
|
||||
const { game_id } = payload;
|
||||
|
||||
if (!game_id) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Game ID required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract action type from message type (remove 'action_' prefix)
|
||||
const actionType = type.replace('action_', '');
|
||||
|
||||
// Forward to game session manager
|
||||
const result = gameSessionManager.handleAction(ws.userId!, game_id, actionType, payload);
|
||||
|
||||
if (!result.success) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'action_failed',
|
||||
payload: {
|
||||
action_type: actionType,
|
||||
error: result.error,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ STARTUP ============
|
||||
|
||||
console.log(`WebSocket server listening on port ${config.wsPort}`);
|
||||
console.log(`Environment: ${config.isDev ? 'development' : 'production'}`);
|
||||
207
server/src/matchmaking/MatchmakingService.ts
Normal file
207
server/src/matchmaking/MatchmakingService.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
// Extended WebSocket type with user info
|
||||
export interface AuthenticatedSocket extends WebSocket {
|
||||
userId?: string;
|
||||
username?: string;
|
||||
isAlive?: boolean;
|
||||
}
|
||||
|
||||
export interface QueuedPlayer {
|
||||
socket: AuthenticatedSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
deckId: string;
|
||||
eloRating: number;
|
||||
joinedAt: number;
|
||||
eloRange: number; // Starts at 100, expands over time
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
player1: QueuedPlayer;
|
||||
player2: QueuedPlayer;
|
||||
firstPlayer: number; // 0 or 1, randomly chosen
|
||||
}
|
||||
|
||||
type MatchFoundCallback = (match: MatchResult) => void;
|
||||
|
||||
export class MatchmakingService {
|
||||
private queue: Map<string, QueuedPlayer> = new Map();
|
||||
private matchCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ELO range configuration
|
||||
private static readonly INITIAL_ELO_RANGE = 100;
|
||||
private static readonly ELO_RANGE_EXPANSION = 50; // Expand by 50
|
||||
private static readonly ELO_RANGE_EXPANSION_INTERVAL = 30; // Every 30 seconds
|
||||
private static readonly MAX_ELO_RANGE = 500;
|
||||
private static readonly MATCH_CHECK_INTERVAL = 2000; // 2 seconds
|
||||
|
||||
constructor(private onMatchFound: MatchFoundCallback) {
|
||||
// Run match check every 2 seconds
|
||||
this.matchCheckInterval = setInterval(
|
||||
() => this.findMatches(),
|
||||
MatchmakingService.MATCH_CHECK_INTERVAL
|
||||
);
|
||||
console.log('MatchmakingService initialized');
|
||||
}
|
||||
|
||||
joinQueue(
|
||||
socket: AuthenticatedSocket,
|
||||
userId: string,
|
||||
username: string,
|
||||
deckId: string,
|
||||
eloRating: number
|
||||
): boolean {
|
||||
// Remove if already in queue (prevents duplicates)
|
||||
this.leaveQueue(userId);
|
||||
|
||||
const player: QueuedPlayer = {
|
||||
socket,
|
||||
userId,
|
||||
username,
|
||||
deckId,
|
||||
eloRating,
|
||||
joinedAt: Date.now(),
|
||||
eloRange: MatchmakingService.INITIAL_ELO_RANGE,
|
||||
};
|
||||
|
||||
this.queue.set(userId, player);
|
||||
|
||||
// Notify client of queue position
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'queue_joined',
|
||||
payload: {
|
||||
position: this.queue.size,
|
||||
eloRating,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`Player ${username} (ELO: ${eloRating}) joined queue. Queue size: ${this.queue.size}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
leaveQueue(userId: string): boolean {
|
||||
const player = this.queue.get(userId);
|
||||
if (player) {
|
||||
this.queue.delete(userId);
|
||||
|
||||
// Only send message if socket is still open
|
||||
if (player.socket.readyState === WebSocket.OPEN) {
|
||||
player.socket.send(JSON.stringify({ type: 'queue_left', payload: {} }));
|
||||
}
|
||||
|
||||
console.log(`Player ${player.username} left queue. Queue size: ${this.queue.size}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private findMatches(): void {
|
||||
if (this.queue.size < 2) {
|
||||
return; // Need at least 2 players to match
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Update ELO ranges for waiting players
|
||||
for (const player of this.queue.values()) {
|
||||
const waitTimeSeconds = (now - player.joinedAt) / 1000;
|
||||
const expansions = Math.floor(waitTimeSeconds / MatchmakingService.ELO_RANGE_EXPANSION_INTERVAL);
|
||||
player.eloRange = Math.min(
|
||||
MatchmakingService.MAX_ELO_RANGE,
|
||||
MatchmakingService.INITIAL_ELO_RANGE + expansions * MatchmakingService.ELO_RANGE_EXPANSION
|
||||
);
|
||||
}
|
||||
|
||||
// Convert to array for iteration
|
||||
const players = Array.from(this.queue.values());
|
||||
const matched = new Set<string>();
|
||||
|
||||
// Find matching pairs
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
if (matched.has(players[i].userId)) continue;
|
||||
|
||||
// Find best match for this player
|
||||
let bestMatch: QueuedPlayer | null = null;
|
||||
let bestEloDiff = Infinity;
|
||||
|
||||
for (let j = i + 1; j < players.length; j++) {
|
||||
if (matched.has(players[j].userId)) continue;
|
||||
|
||||
const eloDiff = Math.abs(players[i].eloRating - players[j].eloRating);
|
||||
const maxRange = Math.max(players[i].eloRange, players[j].eloRange);
|
||||
|
||||
// Check if within acceptable ELO range
|
||||
if (eloDiff <= maxRange && eloDiff < bestEloDiff) {
|
||||
bestMatch = players[j];
|
||||
bestEloDiff = eloDiff;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a match, create the game
|
||||
if (bestMatch) {
|
||||
matched.add(players[i].userId);
|
||||
matched.add(bestMatch.userId);
|
||||
|
||||
this.queue.delete(players[i].userId);
|
||||
this.queue.delete(bestMatch.userId);
|
||||
|
||||
// Randomly determine first player
|
||||
const firstPlayer = Math.random() < 0.5 ? 0 : 1;
|
||||
|
||||
const matchResult: MatchResult = {
|
||||
player1: players[i],
|
||||
player2: bestMatch,
|
||||
firstPlayer,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`Match found: ${players[i].username} (${players[i].eloRating}) vs ` +
|
||||
`${bestMatch.username} (${bestMatch.eloRating}). First player: ${firstPlayer}`
|
||||
);
|
||||
|
||||
this.onMatchFound(matchResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getQueueSize(): number {
|
||||
return this.queue.size;
|
||||
}
|
||||
|
||||
isInQueue(userId: string): boolean {
|
||||
return this.queue.has(userId);
|
||||
}
|
||||
|
||||
getPlayerQueueInfo(userId: string): { position: number; waitTime: number } | null {
|
||||
const player = this.queue.get(userId);
|
||||
if (!player) return null;
|
||||
|
||||
// Calculate position (players are in insertion order)
|
||||
let position = 0;
|
||||
for (const id of this.queue.keys()) {
|
||||
position++;
|
||||
if (id === userId) break;
|
||||
}
|
||||
|
||||
return {
|
||||
position,
|
||||
waitTime: Math.floor((Date.now() - player.joinedAt) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
handleDisconnect(userId: string): void {
|
||||
this.leaveQueue(userId);
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.matchCheckInterval) {
|
||||
clearInterval(this.matchCheckInterval);
|
||||
this.matchCheckInterval = null;
|
||||
}
|
||||
this.queue.clear();
|
||||
console.log('MatchmakingService shutdown');
|
||||
}
|
||||
}
|
||||
26
server/src/matchmaking/RoomCodeGenerator.ts
Normal file
26
server/src/matchmaking/RoomCodeGenerator.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Room code generator - creates 6-character codes from unambiguous charset
|
||||
// Excludes: 0/O (zero/oh), 1/I/L (one/eye/ell) to avoid confusion
|
||||
|
||||
const CHARSET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
const CODE_LENGTH = 6;
|
||||
|
||||
export function generateRoomCode(): string {
|
||||
let code = '';
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CHARSET[Math.floor(Math.random() * CHARSET.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export function isValidRoomCode(code: string): boolean {
|
||||
if (!code || code.length !== CODE_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
const upperCode = code.toUpperCase();
|
||||
for (const char of upperCode) {
|
||||
if (!CHARSET.includes(char)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
383
server/src/matchmaking/RoomManager.ts
Normal file
383
server/src/matchmaking/RoomManager.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { WebSocket } from 'ws';
|
||||
import { generateRoomCode, isValidRoomCode } from './RoomCodeGenerator.js';
|
||||
import { AuthenticatedSocket } from './MatchmakingService.js';
|
||||
|
||||
export interface RoomPlayer {
|
||||
socket: AuthenticatedSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
deckId: string;
|
||||
eloRating: number;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
code: string;
|
||||
host: RoomPlayer;
|
||||
guest: RoomPlayer | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface RoomState {
|
||||
code: string;
|
||||
host: { username: string; ready: boolean };
|
||||
guest: { username: string; ready: boolean } | null;
|
||||
}
|
||||
|
||||
type GameStartCallback = (room: Room) => void;
|
||||
|
||||
export class RoomManager {
|
||||
private rooms: Map<string, Room> = new Map();
|
||||
private userToRoom: Map<string, string> = new Map(); // userId -> roomCode
|
||||
private expiryInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
private static readonly ROOM_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
|
||||
private static readonly EXPIRY_CHECK_INTERVAL = 60 * 1000; // 1 minute
|
||||
private static readonly MAX_CODE_GENERATION_ATTEMPTS = 10;
|
||||
|
||||
constructor(private onGameStart: GameStartCallback) {
|
||||
// Check for expired rooms every minute
|
||||
this.expiryInterval = setInterval(
|
||||
() => this.cleanupExpiredRooms(),
|
||||
RoomManager.EXPIRY_CHECK_INTERVAL
|
||||
);
|
||||
console.log('RoomManager initialized');
|
||||
}
|
||||
|
||||
createRoom(
|
||||
socket: AuthenticatedSocket,
|
||||
userId: string,
|
||||
username: string,
|
||||
deckId: string,
|
||||
eloRating: number
|
||||
): Room | null {
|
||||
// Leave any existing room first
|
||||
this.leaveRoom(userId);
|
||||
|
||||
// Generate unique code
|
||||
let code: string = '';
|
||||
let attempts = 0;
|
||||
do {
|
||||
code = generateRoomCode();
|
||||
attempts++;
|
||||
} while (this.rooms.has(code) && attempts < RoomManager.MAX_CODE_GENERATION_ATTEMPTS);
|
||||
|
||||
if (attempts >= RoomManager.MAX_CODE_GENERATION_ATTEMPTS) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
payload: { message: 'Failed to create room. Please try again.' },
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const room: Room = {
|
||||
code,
|
||||
host: { socket, userId, username, deckId, eloRating, ready: false },
|
||||
guest: null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
this.rooms.set(code, room);
|
||||
this.userToRoom.set(userId, code);
|
||||
|
||||
// Notify the creator
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_created',
|
||||
payload: this.getRoomState(room),
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`Room ${code} created by ${username}`);
|
||||
return room;
|
||||
}
|
||||
|
||||
joinRoom(
|
||||
code: string,
|
||||
socket: AuthenticatedSocket,
|
||||
userId: string,
|
||||
username: string,
|
||||
deckId: string,
|
||||
eloRating: number
|
||||
): Room | null {
|
||||
code = code.toUpperCase();
|
||||
|
||||
// Validate code format
|
||||
if (!isValidRoomCode(code)) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
payload: { message: 'Invalid room code format' },
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const room = this.rooms.get(code);
|
||||
|
||||
if (!room) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
payload: { message: 'Room not found' },
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if trying to join own room
|
||||
if (room.host.userId === userId) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
payload: { message: 'Cannot join your own room' },
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (room.guest) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
payload: { message: 'Room is full' },
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Leave any existing room first
|
||||
this.leaveRoom(userId);
|
||||
|
||||
// Add guest to room
|
||||
room.guest = { socket, userId, username, deckId, eloRating, ready: false };
|
||||
this.userToRoom.set(userId, code);
|
||||
|
||||
const roomState = this.getRoomState(room);
|
||||
|
||||
// Notify the guest
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_joined',
|
||||
payload: roomState,
|
||||
})
|
||||
);
|
||||
|
||||
// Notify the host
|
||||
if (room.host.socket.readyState === WebSocket.OPEN) {
|
||||
room.host.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_updated',
|
||||
payload: roomState,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Player ${username} joined room ${code}`);
|
||||
return room;
|
||||
}
|
||||
|
||||
leaveRoom(userId: string): void {
|
||||
const code = this.userToRoom.get(userId);
|
||||
if (!code) return;
|
||||
|
||||
const room = this.rooms.get(code);
|
||||
if (!room) {
|
||||
this.userToRoom.delete(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.host.userId === userId) {
|
||||
// Host leaving - close room entirely
|
||||
console.log(`Host ${room.host.username} left room ${code}, closing room`);
|
||||
|
||||
// Notify guest if present
|
||||
if (room.guest && room.guest.socket.readyState === WebSocket.OPEN) {
|
||||
room.guest.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_left',
|
||||
payload: { reason: 'Host left the room' },
|
||||
})
|
||||
);
|
||||
this.userToRoom.delete(room.guest.userId);
|
||||
}
|
||||
|
||||
// Notify host
|
||||
if (room.host.socket.readyState === WebSocket.OPEN) {
|
||||
room.host.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_left',
|
||||
payload: {},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.rooms.delete(code);
|
||||
this.userToRoom.delete(userId);
|
||||
} else if (room.guest?.userId === userId) {
|
||||
// Guest leaving
|
||||
console.log(`Guest ${room.guest.username} left room ${code}`);
|
||||
|
||||
// Notify guest
|
||||
if (room.guest.socket.readyState === WebSocket.OPEN) {
|
||||
room.guest.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_left',
|
||||
payload: {},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
room.guest = null;
|
||||
this.userToRoom.delete(userId);
|
||||
|
||||
// Notify host of updated room state
|
||||
if (room.host.socket.readyState === WebSocket.OPEN) {
|
||||
room.host.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_updated',
|
||||
payload: this.getRoomState(room),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setReady(userId: string, ready: boolean): void {
|
||||
const code = this.userToRoom.get(userId);
|
||||
if (!code) return;
|
||||
|
||||
const room = this.rooms.get(code);
|
||||
if (!room) return;
|
||||
|
||||
// Update ready status
|
||||
if (room.host.userId === userId) {
|
||||
room.host.ready = ready;
|
||||
} else if (room.guest?.userId === userId) {
|
||||
room.guest.ready = ready;
|
||||
} else {
|
||||
return; // User not in this room
|
||||
}
|
||||
|
||||
const roomState = this.getRoomState(room);
|
||||
|
||||
// Broadcast update to both players
|
||||
if (room.host.socket.readyState === WebSocket.OPEN) {
|
||||
room.host.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_updated',
|
||||
payload: roomState,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (room.guest?.socket.readyState === WebSocket.OPEN) {
|
||||
room.guest.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_updated',
|
||||
payload: roomState,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Player in room ${code} set ready: ${ready}`);
|
||||
|
||||
// Check if both players are ready
|
||||
if (room.host.ready && room.guest?.ready) {
|
||||
console.log(`Both players ready in room ${code}, starting game`);
|
||||
|
||||
// Remove room from tracking (game session will take over)
|
||||
this.rooms.delete(code);
|
||||
this.userToRoom.delete(room.host.userId);
|
||||
this.userToRoom.delete(room.guest.userId);
|
||||
|
||||
// Trigger game start callback
|
||||
this.onGameStart(room);
|
||||
}
|
||||
}
|
||||
|
||||
getRoomByCode(code: string): Room | undefined {
|
||||
return this.rooms.get(code.toUpperCase());
|
||||
}
|
||||
|
||||
getRoomByUserId(userId: string): Room | undefined {
|
||||
const code = this.userToRoom.get(userId);
|
||||
return code ? this.rooms.get(code) : undefined;
|
||||
}
|
||||
|
||||
isInRoom(userId: string): boolean {
|
||||
return this.userToRoom.has(userId);
|
||||
}
|
||||
|
||||
handleDisconnect(userId: string): void {
|
||||
this.leaveRoom(userId);
|
||||
}
|
||||
|
||||
private getRoomState(room: Room): RoomState {
|
||||
return {
|
||||
code: room.code,
|
||||
host: {
|
||||
username: room.host.username,
|
||||
ready: room.host.ready,
|
||||
},
|
||||
guest: room.guest
|
||||
? {
|
||||
username: room.guest.username,
|
||||
ready: room.guest.ready,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private cleanupExpiredRooms(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [code, room] of this.rooms.entries()) {
|
||||
if (now - room.createdAt > RoomManager.ROOM_EXPIRY_MS) {
|
||||
console.log(`Room ${code} expired, cleaning up`);
|
||||
|
||||
// Notify host
|
||||
if (room.host.socket.readyState === WebSocket.OPEN) {
|
||||
room.host.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_left',
|
||||
payload: { reason: 'Room expired' },
|
||||
})
|
||||
);
|
||||
}
|
||||
this.userToRoom.delete(room.host.userId);
|
||||
|
||||
// Notify guest if present
|
||||
if (room.guest) {
|
||||
if (room.guest.socket.readyState === WebSocket.OPEN) {
|
||||
room.guest.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_left',
|
||||
payload: { reason: 'Room expired' },
|
||||
})
|
||||
);
|
||||
}
|
||||
this.userToRoom.delete(room.guest.userId);
|
||||
}
|
||||
|
||||
this.rooms.delete(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRoomCount(): number {
|
||||
return this.rooms.size;
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.expiryInterval) {
|
||||
clearInterval(this.expiryInterval);
|
||||
this.expiryInterval = null;
|
||||
}
|
||||
this.rooms.clear();
|
||||
this.userToRoom.clear();
|
||||
console.log('RoomManager shutdown');
|
||||
}
|
||||
}
|
||||
20
server/tsconfig.json
Normal file
20
server/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user