feature updates

This commit is contained in:
2026-02-02 16:28:53 -05:00
parent bf9aa3fa23
commit 44c06530ac
83 changed files with 282641 additions and 11251 deletions

31
server/.env.example Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

43
server/package.json Normal file
View 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
View 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;

View 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' };
}

View 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>
`,
});
}

View 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
View 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',
};

View 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;
}

View 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")
}

View 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));
}

View 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(),
};
}
}

View 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');
}
}

View 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;
}
}

View 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);
});
});
});

View 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
});
});
});

View 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();
});
});
});

View 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
View 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'}`);

View 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');
}
}

View 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;
}

View 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
View 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"]
}