-
- Welcome, {user.username}!
-
-
-
- Email: {user.email}
-
-
- Member since: {new Date(user.created_at).toLocaleDateString()}
-
+
+ {user && (
+
+ {/* Profile Section */}
+
+
+
+
+ {user.username.charAt(0).toUpperCase()}
+
+
{user.username}
+
{user.email}
+
+ Member since {new Date(user.created_at).toLocaleDateString()}
+
+
+
+
-
+
+ {/* Recent Games */}
+ {recentGames.length > 0 && (
+
+
Recent Games
+
+ {recentGames.map((game) => {
+ const myRecord = game.players.find((p) => p.player.id === user.id);
+ return (
+
+
+
+ #{myRecord?.place ?? '?'}
+
+
+
+ {game.players.length} player{game.players.length !== 1 ? 's' : ''}
+
+
+ {new Date(game.finished_at).toLocaleDateString()} at{' '}
+ {new Date(game.finished_at).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+
+
{myRecord?.score.toLocaleString() ?? 0}
+
points
+
+
+ );
+ })}
+
+
+ )}
+
+ )}
);
}
-
-const resources = [
- {
- href: 'https://reactrouter.com/docs',
- text: 'React Router Docs',
- icon: (
-
- ),
- },
- {
- href: 'https://rmx.as/discord',
- text: 'Join Discord',
- icon: (
-
- ),
- },
-];
diff --git a/client/package.json b/client/package.json
index a46263a..01e9b85 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,9 +1,9 @@
{
"name": "client",
"private": true,
- "type": "commonjs",
+ "type": "module",
"scripts": {
- "dev": "react-router dev --port 3001 --host 127.0.0.1",
+ "dev": "sh -c 'react-router dev --port ${CLIENT_PORT:-3001} --host 0.0.0.0'",
"build": "react-router build",
"start": "react-router-serve build/server/index.js",
"lint": "eslint . --ext js,jsx,ts,tsx --ignore-pattern .react-router --ignore-pattern build --ignore-pattern node_modules --fix",
diff --git a/client/vite.config.ts b/client/vite.config.ts
index ab1dd43..8904849 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -2,5 +2,36 @@ import { reactRouter } from '@react-router/dev/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
+import os from 'os';
-export default defineConfig({ plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], envDir: '..' });
+// Helper function to get the first network IP
+function getNetworkIP() {
+ const interfaces = os.networkInterfaces();
+
+ for (const name of Object.keys(interfaces)) {
+ for (const networkInterface of interfaces[name]) {
+ if (networkInterface.family === 'IPv4' && !networkInterface.internal) {
+ return networkInterface.address;
+ }
+ }
+ }
+ return 'localhost';
+}
+
+// Dynamic server URL based on environment
+const serverPort = process.env.SERVER_PORT ?? '3002';
+const clientPort = parseInt(process.env.CLIENT_PORT ?? '3001', 10);
+const serverUrl =
+ process.env.VITE_SERVER_URL === 'auto'
+ ? `http://${getNetworkIP()}:${serverPort}`
+ : (process.env.VITE_SERVER_URL ?? `http://localhost:${serverPort}`);
+
+export default defineConfig({
+ plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
+ envDir: '..',
+ server: {
+ host: '0.0.0.0', // Allow external connections
+ port: clientPort,
+ },
+ define: { 'import.meta.env.VITE_SERVER_URL': JSON.stringify(serverUrl) },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d685796..b204625 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -194,6 +194,9 @@ importers:
'@types/node':
specifier: ^25.0.10
version: 25.0.10
+ '@types/proxyquire':
+ specifier: ^1.3.31
+ version: 1.3.31
'@types/sinon':
specifier: ^21.0.0
version: 21.0.0
@@ -212,6 +215,9 @@ importers:
nyc:
specifier: ^17.1.0
version: 17.1.0
+ proxyquire:
+ specifier: ^2.1.3
+ version: 2.1.3
sinon:
specifier: ^21.0.1
version: 21.0.1
@@ -1017,6 +1023,9 @@ packages:
'@types/node@25.0.10':
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
+ '@types/proxyquire@1.3.31':
+ resolution: {integrity: sha512-uALowNG2TSM1HNPMMOR0AJwv4aPYPhqB0xlEhkeRTMuto5hjoSPZkvgu1nbPUkz3gEPAHv4sy4DmKsurZiEfRQ==}
+
'@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
@@ -1751,6 +1760,10 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
+ fill-keys@1.0.2:
+ resolution: {integrity: sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==}
+ engines: {node: '>=0.10.0'}
+
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -2074,6 +2087,9 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ is-object@1.0.2:
+ resolution: {integrity: sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==}
+
is-path-inside@3.0.3:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
engines: {node: '>=8'}
@@ -2451,6 +2467,9 @@ packages:
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true
+ module-not-found-error@1.0.1:
+ resolution: {integrity: sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==}
+
morgan@1.10.1:
resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==}
engines: {node: '>= 0.8.0'}
@@ -2685,6 +2704,9 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
+ proxyquire@2.1.3:
+ resolution: {integrity: sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==}
+
pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
@@ -2794,6 +2816,11 @@ packages:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
+ resolve@1.22.11:
+ resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
resolve@2.0.0-next.5:
resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
hasBin: true
@@ -4170,6 +4197,8 @@ snapshots:
dependencies:
undici-types: 7.16.0
+ '@types/proxyquire@1.3.31': {}
+
'@types/qs@6.14.0': {}
'@types/range-parser@1.2.7': {}
@@ -5129,6 +5158,11 @@ snapshots:
dependencies:
flat-cache: 4.0.1
+ fill-keys@1.0.2:
+ dependencies:
+ is-object: 1.0.2
+ merge-descriptors: 1.0.3
+
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -5451,6 +5485,8 @@ snapshots:
is-number@7.0.0: {}
+ is-object@1.0.2: {}
+
is-path-inside@3.0.3: {}
is-plain-obj@2.1.0: {}
@@ -5809,6 +5845,8 @@ snapshots:
yargs-parser: 21.1.1
yargs-unparser: 2.0.0
+ module-not-found-error@1.0.1: {}
+
morgan@1.10.1:
dependencies:
basic-auth: 2.0.1
@@ -6072,6 +6110,12 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+ proxyquire@2.1.3:
+ dependencies:
+ fill-keys: 1.0.2
+ module-not-found-error: 1.0.1
+ resolve: 1.22.11
+
pstree.remy@1.1.8: {}
punycode@2.3.1: {}
@@ -6169,6 +6213,12 @@ snapshots:
resolve-from@5.0.0: {}
+ resolve@1.22.11:
+ dependencies:
+ is-core-module: 2.16.1
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
resolve@2.0.0-next.5:
dependencies:
is-core-module: 2.16.1
diff --git a/server/app.ts b/server/app.ts
index cd1a96b..cf947de 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -1,8 +1,6 @@
import dotenv from 'dotenv';
import path from 'path';
-dotenv.config({ path: path.resolve(process.cwd(), '..', '.env') });
-
import cors from 'cors';
import express, { Request, Response } from 'express';
import http from 'http';
@@ -13,20 +11,47 @@ import { GameManager } from './core/GameManager';
import db, { runMigrations, testConnection } from './db';
import { registerSocketHandlers } from './net/socketHandlers';
import authRoutes from './routes/auth';
+import gamesRoutes from './routes/games';
import { ClientToServerEvents, ServerToClientEvents, SocketData } from './types/socketEvents';
import { verifyToken } from './utils/jwt';
import { logDbError, logger } from './utils/logger';
+dotenv.config({ path: path.resolve(process.cwd(), '..', '.env') });
+
+import os from 'os';
+
const app = express();
const server = http.createServer(app);
-const clientUrlsRaw = process.env.CLIENT_URL ?? 'http://localhost:3001';
-const allowedOrigins = clientUrlsRaw
+// Helper function to get network IPs
+function getNetworkIPs(): string[] {
+ const interfaces = os.networkInterfaces();
+ const ips: string[] = [];
+
+ for (const name of Object.keys(interfaces)) {
+ for (const networkInterface of interfaces[name] ?? []) {
+ if (networkInterface.family === 'IPv4' && !networkInterface.internal) {
+ ips.push(networkInterface.address);
+ }
+ }
+ }
+ return ips;
+}
+
+// Dynamic CORS origins - include current network IPs
+const clientPort = process.env.CLIENT_PORT ?? '3001';
+const clientUrlsRaw = process.env.CLIENT_URL ?? `http://localhost:${clientPort}`;
+const baseOrigins = clientUrlsRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
+// Add current network IPs dynamically
+const networkIPs = getNetworkIPs();
+const dynamicOrigins = networkIPs.map((ip) => `http://${ip}:${clientPort}`);
+const allowedOrigins = [...baseOrigins, ...dynamicOrigins];
+
const io = new IOServer
(server, {
cors: { origin: allowedOrigins, credentials: true },
});
@@ -131,6 +156,8 @@ app.get('/health', async (req: Request, res: Response) => {
// Auth routes
app.use('/api/auth', authRoutes);
+// Games routes
+app.use('/api/games', gamesRoutes);
// Example database query endpoint
app.get('/api/test-db', async (req: Request, res: Response) => {
@@ -167,7 +194,7 @@ const startServer = async () => {
process.exit(1);
}
- server.listen(SERVER_PORT, () => {
+ server.listen(SERVER_PORT, '0.0.0.0', () => {
logger.info(`Server (HTTP + Socket.IO) is running on port ${SERVER_PORT}`);
});
};
diff --git a/server/controllers/gamesController.ts b/server/controllers/gamesController.ts
new file mode 100644
index 0000000..9b958c5
--- /dev/null
+++ b/server/controllers/gamesController.ts
@@ -0,0 +1,69 @@
+import { Response } from 'express';
+import db from '../db';
+import { AuthRequest } from '../middleware/auth';
+import { GameDTO, GameRow } from '../models/Game';
+import { paginate } from '../routes/pagination';
+import { logDbError } from '../utils/logger';
+
+const DEFAULT_PAGE_SIZE = 10;
+
+export const getUserGames = async (req: AuthRequest, res: Response): Promise => {
+ const userId = req.user?.userId;
+ if (!userId) {
+ res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'User not authenticated' } });
+ return;
+ }
+
+ try {
+ const pageParam = req.query.page;
+ const sizeParam = req.query.size;
+
+ let page = 0;
+ let size = DEFAULT_PAGE_SIZE;
+
+ if (typeof pageParam === 'string' && pageParam.trim() !== '') {
+ const p = Number.parseInt(pageParam);
+ if (!Number.isNaN(p) && p >= 0) page = p;
+ }
+
+ if (typeof sizeParam === 'string' && sizeParam.trim() !== '') {
+ const s = Number.parseInt(sizeParam);
+ if (!Number.isNaN(s) && s > 0) size = s;
+ }
+
+ const [rows] = await db.query(
+ `SELECT g.id AS game_id,
+ g.finished_at AS finished_at,
+ gr.player_id AS player_id,
+ u.username AS player_username,
+ gr.score AS score,
+ gr.place AS place
+ FROM games g
+ JOIN game_records gr ON gr.game_id = g.id
+ JOIN users u ON gr.player_id = u.id
+ WHERE g.id IN (SELECT game_id FROM game_records WHERE player_id = ?)
+ ORDER BY g.id DESC, gr.score DESC`,
+ [userId]
+ );
+
+ const gamesMap = new Map();
+
+ for (const r of rows) {
+ if (!gamesMap.has(r.game_id)) {
+ gamesMap.set(r.game_id, { game_id: r.game_id, finished_at: r.finished_at, players: [] });
+ }
+
+ const record: GameDTO = gamesMap.get(r.game_id)!;
+ record.players.push({ player: { id: r.player_id, username: r.player_username }, score: r.score, place: r.place });
+ }
+
+ const games = Array.from(gamesMap.values()).sort((a, b) => b.game_id - a.game_id);
+
+ res.json({ success: true, data: paginate(games, page, size) });
+ } catch (err: unknown) {
+ logDbError('GET /api/games', err);
+ res.status(500).json({ success: false, error: { code: 'GAMES_FETCH_FAILED', message: 'Failed to fetch games' } });
+ }
+};
+
+export default { getUserGames };
diff --git a/server/core/GameService.ts b/server/core/GameService.ts
new file mode 100644
index 0000000..0f05c20
--- /dev/null
+++ b/server/core/GameService.ts
@@ -0,0 +1,45 @@
+import { ResultSetHeader } from 'mysql2';
+import db from '../db';
+import { Game } from '../game/Game';
+
+export class GameService {
+ async saveFinishedGame(game: Game): Promise {
+ const conn = await db.getConnection();
+ try {
+ await conn.beginTransaction();
+
+ const finishedAt = new Date();
+ const [res] = await conn.query('INSERT INTO games (finished_at) VALUES (?)', [finishedAt]);
+
+ const gameId = res.insertId;
+
+ const playerScores = game
+ .getPlayers()
+ .map((p) => ({
+ id: p.id,
+ score: game.getState().playerStates[p.id].score,
+ diedAt: game.getState().playerStates[p.id].diedAt ?? new Date(),
+ }));
+ playerScores.sort((a, b) => b.diedAt.getTime() - a.diedAt.getTime() || b.score - a.score);
+
+ for (const [index, ps] of playerScores.entries()) {
+ await conn.query('INSERT INTO game_records (game_id, player_id, score, place) VALUES (?, ?, ?, ?)', [
+ gameId,
+ ps.id,
+ ps.score,
+ index + 1,
+ ]);
+ }
+
+ await conn.commit();
+ return gameId;
+ } catch (err) {
+ await conn.rollback();
+ throw err;
+ } finally {
+ conn.release();
+ }
+ }
+}
+
+export default new GameService();
diff --git a/server/core/Player.ts b/server/core/Player.ts
index e9386f3..4e76cd0 100644
--- a/server/core/Player.ts
+++ b/server/core/Player.ts
@@ -3,6 +3,7 @@ export class Player {
name: string;
socketId: string;
isAlive = true;
+ diedAt: Date | null = null;
constructor(id: string, name: string, socketId: string) {
this.id = id;
diff --git a/server/db.ts b/server/db.ts
index e3bf3d3..973a060 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -1,24 +1,33 @@
+import dotenv from 'dotenv';
+import path from 'path';
+
import fs from 'fs/promises';
import mysql from 'mysql2/promise';
-import path from 'path'; // use platform path, not path/posix
import { logger } from './utils/logger';
+dotenv.config({ path: path.resolve(process.cwd(), '..', '.env') });
+
+logger.info('ENV used:', process.env.NODE_ENV);
+
const dbPortRaw = process.env.DB_PORT ?? '3306';
-const dbPort = Number.parseInt(dbPortRaw, 10);
-if (Number.isNaN(dbPort)) {
- throw new Error(`Invalid DB_PORT value "${dbPortRaw}". It must be a valid integer.`);
-}
+let dbPort = Number.parseInt(dbPortRaw, 10);
+dbPort = Number.isNaN(dbPort) ? 3306 : dbPort;
+
+let dbPassword, dbName;
-const dbPassword = process.env.DB_PASSWORD;
-if (!dbPassword) {
- throw new Error('DB_PASSWORD environment variable is required for database connection');
+if (process.env.NODE_ENV === 'test') {
+ dbPassword = process.env.DB_PASSWORD_TEST ?? 'app_change_me';
+ dbName = process.env.DB_NAME_TEST ?? 'red_tetris_test';
+} else {
+ dbPassword = process.env.DB_PASSWORD ?? 'app_change_me';
+ dbName = process.env.DB_NAME ?? 'red_tetris';
}
const pool = mysql.createPool({
- host: process.env.DB_HOST ?? 'localhost',
- user: process.env.DB_USER ?? 'app',
+ host: process.env.DB_HOST,
+ user: process.env.DB_USER,
password: dbPassword,
- database: process.env.DB_NAME ?? 'red_tetris',
+ database: dbName,
port: dbPort,
waitForConnections: true,
connectionLimit: 10,
@@ -32,12 +41,8 @@ export const testConnection = async () => {
logger.info('✅ Database connected successfully');
connection.release();
return true;
- } catch (error: unknown) {
- if (error instanceof Error) {
- logger.error('❌ Database connection failed:', error.message);
- } else {
- logger.error('❌ Database connection failed with unknown error');
- }
+ } catch {
+ logger.error('❌ Database connection failed');
return false;
}
};
@@ -94,7 +99,6 @@ export const runMigrations = async (): Promise => {
logger.info('All migrations applied successfully');
} catch (err: unknown) {
logger.error('Migrations failed:', err);
- throw err;
} finally {
if (conn) conn.release();
}
diff --git a/server/game/GameEngine.ts b/server/game/GameEngine.ts
index 7d95539..c56c15d 100644
--- a/server/game/GameEngine.ts
+++ b/server/game/GameEngine.ts
@@ -1,5 +1,5 @@
import { logger } from '../utils/logger';
-import { BSP_SCORES, PLAYGROUND_HEIGHT, PLAYGROUND_WIDTH, TETROMINOS, STANDARD_FIRST_SPEED } from './contstants';
+import { BSP_SCORES, PLAYGROUND_HEIGHT, PLAYGROUND_WIDTH, STANDARD_FIRST_SPEED, TETROMINOS } from './contstants';
import { PieceGenerator } from './PieceGenerator';
import { GameState, Intent, Piece, PlayerState } from './types';
@@ -52,6 +52,7 @@ export class GameEngine {
holdLocked: false,
score: 0,
isAlive: true,
+ diedAt: null,
name: '',
},
])
@@ -78,6 +79,7 @@ export class GameEngine {
holdLocked: false,
score: 0,
isAlive: true,
+ diedAt: null,
name: '',
};
return newState;
@@ -239,6 +241,7 @@ export class GameEngine {
if (this.collides(player.board, spawn)) {
// spawn collision -> player dies
player.isAlive = false;
+ player.diedAt = new Date();
player.activePiece = null;
} else {
player.activePiece = spawn;
@@ -274,8 +277,19 @@ export class GameEngine {
if (!this.collides(player.board, rotated)) {
player.activePiece = rotated;
} else {
- for (const dy of [-1, -2]) {
- const candidate: Piece = { ...rotated, y: base.y + dy };
+ // Wall kick offsets: try shifting horizontally, then vertically, then combos
+ const kicks = [
+ [1, 0],
+ [-1, 0],
+ [2, 0],
+ [-2, 0],
+ [0, -1],
+ [1, -1],
+ [-1, -1],
+ [0, -2],
+ ];
+ for (const [dx, dy] of kicks) {
+ const candidate: Piece = { ...rotated, x: base.x + dx, y: base.y + dy };
if (!this.collides(player.board, candidate)) {
player.activePiece = candidate;
break;
@@ -351,6 +365,7 @@ export class GameEngine {
if (this.collides(playerState.board, piece)) {
logger.info(`player ${playerId} collided on spawn and dies`);
playerState.isAlive = false;
+ playerState.diedAt = new Date();
continue;
}
}
diff --git a/server/game/types.ts b/server/game/types.ts
index 08f9712..4c03ab3 100644
--- a/server/game/types.ts
+++ b/server/game/types.ts
@@ -22,6 +22,7 @@ export interface PlayerState {
holdLocked: boolean;
score: number;
isAlive: boolean;
+ diedAt: Date | null;
name: string;
}
diff --git a/server/migrations/002_create_games_and_records.sql b/server/migrations/002_create_games_and_records.sql
new file mode 100644
index 0000000..5a04d13
--- /dev/null
+++ b/server/migrations/002_create_games_and_records.sql
@@ -0,0 +1,21 @@
+-- Create games table
+CREATE TABLE IF NOT EXISTS games (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ finished_at TIMESTAMP,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Create game_records table (records of players and their scores per game)
+CREATE TABLE IF NOT EXISTS game_records (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ game_id INT NOT NULL,
+ player_id INT NOT NULL,
+ score INT NOT NULL DEFAULT 0,
+ place INT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE,
+ FOREIGN KEY (player_id) REFERENCES users(id) ON DELETE CASCADE,
+ INDEX idx_game_id (game_id),
+ INDEX idx_player_id (player_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/server/models/Game.ts b/server/models/Game.ts
new file mode 100644
index 0000000..4c680d7
--- /dev/null
+++ b/server/models/Game.ts
@@ -0,0 +1,22 @@
+import { RowDataPacket } from 'mysql2';
+
+export interface GameRow extends RowDataPacket {
+ game_id: number;
+ finished_at: Date;
+ player_id: number;
+ player_username: string;
+ score: number;
+ place: number;
+}
+
+export interface GameRecordDto {
+ player: { id: number; username: string };
+ score: number;
+ place: number;
+}
+
+export interface GameDTO {
+ game_id: number;
+ finished_at: Date;
+ players: GameRecordDto[];
+}
diff --git a/server/net/socketHandlers.ts b/server/net/socketHandlers.ts
index 48d8b48..6157b3a 100644
--- a/server/net/socketHandlers.ts
+++ b/server/net/socketHandlers.ts
@@ -1,12 +1,13 @@
import { Server, Socket } from 'socket.io';
import { GameManager } from '../core/GameManager';
+import GameService from '../core/GameService';
import { Player } from '../core/Player';
+import { TICK_INTERVAL_MS } from '../game/contstants';
import { Game } from '../game/Game';
import { GameEngine } from '../game/GameEngine';
import type { Intent, PlayerState } from '../game/types';
import { ClientToServerEvents, ServerToClientEvents, SocketData } from '../types/socketEvents';
import { logger } from '../utils/logger';
-import { TICK_INTERVAL_MS } from '../game/contstants';
export function registerSocketHandlers(
io: Server,
@@ -22,8 +23,10 @@ export function registerSocketHandlers(
function emitSanitizedState(game: Game) {
const state = game.getState();
- const alivePlayers = game.getPlayers().filter((p) => p.isAlive);
- const winnerId = alivePlayers.length === 1 ? alivePlayers[0].id : null;
+ const alivePlayerIds = Object.entries(state.playerStates)
+ .filter(([, ps]) => ps.isAlive)
+ .map(([id]) => id);
+ const winnerId = state.status === 'finished' && alivePlayerIds.length === 1 ? alivePlayerIds[0] : null;
const playersObj: Record = {};
@@ -39,6 +42,7 @@ export function registerSocketHandlers(
piecesPlaced: playerState.piecesPlaced,
score: playerState.score,
isAlive: playerState.isAlive,
+ diedAt: playerState.diedAt,
name: playerMeta?.name ?? playerState.name,
};
}
@@ -193,6 +197,9 @@ export function registerSocketHandlers(
(initialPlayerCount === 1 && aliveIds.length === 0);
if (shouldEnd) {
+ // ensure status is marked finished so clients see it
+ game.state.status = 'finished';
+
if (initialPlayerCount > 1 && aliveIds.length === 1) {
const winnerId = aliveIds[0];
logger.info(`server: winner detected: ${winnerId}`);
@@ -230,6 +237,11 @@ export function registerSocketHandlers(
gameIntervals.delete(game.id);
}
emitSanitizedState(game);
+
+ // persist finished game and records (fire-and-forget with logging)
+ GameService.saveFinishedGame(game)
+ .then((gid) => logger.info(`Persisted finished game id=${gid} for room ${game.id}`))
+ .catch((err) => logger.error('Failed to persist finished game', err));
}
} catch (err) {
logger.error('game tick error', err);
diff --git a/server/package.json b/server/package.json
index 3965aef..2c9506c 100644
--- a/server/package.json
+++ b/server/package.json
@@ -11,7 +11,7 @@
"prettier": "prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --ignore-path ../.prettierignore --write",
"prettier:check": "prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --ignore-path ../.prettierignore --list-different",
"start": "node dist/app.js",
- "test": "mocha -r ts-node/register tests/**/*.ts",
+ "test": "mocha --file tests/setup.test.ts -r ts-node/register tests/**/*.ts",
"coverage": "nyc mocha -r ts-node/register tests/**/*.ts"
},
"keywords": [],
@@ -27,15 +27,17 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/mocha": "^10.0.10",
"@types/node": "^25.0.10",
+ "@types/proxyquire": "^1.3.31",
"@types/sinon": "^21.0.0",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
- "nodemon": "^3.1.11",
- "ts-node": "^10.9.2",
- "typescript": "^5.9.3",
"mocha": "^11.7.5",
+ "nodemon": "^3.1.11",
"nyc": "^17.1.0",
- "sinon": "^21.0.1"
+ "proxyquire": "^2.1.3",
+ "sinon": "^21.0.1",
+ "ts-node": "^10.9.2",
+ "typescript": "^5.9.3"
},
"dependencies": {
"bcrypt": "^6.0.0",
diff --git a/server/routes/games.ts b/server/routes/games.ts
new file mode 100644
index 0000000..0801f79
--- /dev/null
+++ b/server/routes/games.ts
@@ -0,0 +1,95 @@
+import { Router } from 'express';
+import { getUserGames } from '../controllers/gamesController';
+import { authenticate } from '../middleware/auth';
+
+const router = Router();
+
+/**
+ * @swagger
+ * /api/games:
+ * get:
+ * summary: Get games for authenticated user
+ * tags: [Games]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: query
+ * name: page
+ * schema:
+ * type: integer
+ * default: 0
+ * description: Page number (0-based)
+ * - in: query
+ * name: size
+ * schema:
+ * type: integer
+ * default: 10
+ * description: Page size (number of games per page)
+ * responses:
+ * 200:
+ * description: Paginated games the user participated in
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * example: true
+ * data:
+ * type: object
+ * properties:
+ * page:
+ * type: integer
+ * example: 0
+ * size:
+ * type: integer
+ * example: 10
+ * totalItems:
+ * type: integer
+ * example: 42
+ * totalPages:
+ * type: integer
+ * example: 5
+ * content:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * game_id:
+ * type: integer
+ * finished_at:
+ * type: string
+ * format: date-time
+ * players:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * player:
+ * type: object
+ * properties:
+ * id:
+ * type: integer
+ * username:
+ * type: string
+ * score:
+ * type: integer
+ * place:
+ * type: integer
+ * 401:
+ * description: Unauthorized - No or invalid token
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/ErrorResponse'
+ * 500:
+ * description: Server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/ErrorResponse'
+ */
+router.get('/', authenticate, getUserGames);
+
+export default router;
diff --git a/server/routes/pagination.ts b/server/routes/pagination.ts
new file mode 100644
index 0000000..eeb9258
--- /dev/null
+++ b/server/routes/pagination.ts
@@ -0,0 +1,15 @@
+import { PaginationResult } from '../types/pagination';
+
+export function paginate(items: T[], page: number, size: number): PaginationResult {
+ const totalItems = items.length;
+ const totalPages = Math.ceil(totalItems / size);
+
+ if (page * size >= totalItems) {
+ return { page, size, totalItems, totalPages, content: [] };
+ }
+
+ const startIndex = page * size;
+ const endIndex = startIndex + size;
+ const paginatedItems = items.slice(startIndex, endIndex);
+ return { page, size, totalItems, totalPages, content: paginatedItems };
+}
diff --git a/server/tests/db.test.ts b/server/tests/db.test.ts
new file mode 100644
index 0000000..cd04cf7
--- /dev/null
+++ b/server/tests/db.test.ts
@@ -0,0 +1,44 @@
+import proxyquire from 'proxyquire';
+import sinon from 'sinon';
+
+type DBModule = typeof import('../../server/db');
+
+describe('db migrations branches', () => {
+ it('skips when no migrations dir', async () => {
+ const fakeFs = { stat: sinon.stub().resolves(null) };
+
+ const fakeConn = { query: sinon.stub().resolves([[]]), release: sinon.stub() };
+
+ const fakePool = { getConnection: () => Promise.resolve(fakeConn) };
+ const fakeMysql = { createPool: () => fakePool };
+
+ const db = proxyquire('../../server/db', { 'fs/promises': fakeFs, 'mysql2/promise': fakeMysql });
+ await db.runMigrations();
+ sinon.assert.calledOnce(fakeFs.stat);
+ });
+
+ it('applies migrations when files exist and not applied', async () => {
+ const fakeFs = {
+ stat: sinon.stub().resolves({ isDirectory: () => true }),
+ readdir: sinon.stub().resolves(['001.sql']),
+ readFile: sinon.stub().resolves('CREATE TABLE foo();'),
+ };
+
+ const fakeConn = {
+ query: sinon.stub().callsFake((sql: string) => {
+ if (typeof sql === 'string' && sql.includes('SELECT 1 FROM migrations')) {
+ return [[]];
+ }
+ return [];
+ }),
+ release: sinon.stub(),
+ };
+
+ const fakePool = { getConnection: () => Promise.resolve(fakeConn) };
+ const fakeMysql = { createPool: () => fakePool };
+
+ const db = proxyquire('../../server/db', { 'fs/promises': fakeFs, 'mysql2/promise': fakeMysql });
+ await db.runMigrations();
+ sinon.assert.called(fakeConn.query);
+ });
+});
diff --git a/server/tests/setup.test.ts b/server/tests/setup.test.ts
new file mode 100644
index 0000000..1823fdf
--- /dev/null
+++ b/server/tests/setup.test.ts
@@ -0,0 +1,42 @@
+import dotenv from 'dotenv';
+import path from 'path';
+
+// Ensure tests run in test mode and load test env vars
+process.env.NODE_ENV = process.env.NODE_ENV ?? 'test';
+dotenv.config({ path: path.resolve(process.cwd(), '.env.test') });
+
+import db, { runMigrations, testConnection } from '../db';
+import { logger } from '../utils/logger';
+
+before(async function () {
+ logger.debug('Setting up test database connection and running migrations');
+ const connectionOk = await testConnection();
+ if (connectionOk === false) {
+ logger.error('Test database connection failed');
+ throw new Error('Failed to connect to test database');
+ }
+ await runMigrations();
+});
+
+afterEach(async () => {
+ const conn = await db.getConnection();
+ try {
+ await conn.query('SET FOREIGN_KEY_CHECKS = 0');
+ await conn.query('TRUNCATE TABLE games');
+ await conn.query('TRUNCATE TABLE users');
+ await conn.query('TRUNCATE TABLE game_records');
+ } finally {
+ conn.release();
+ }
+});
+
+after(async () => {
+ logger.debug('All tests done, closing database connection pool');
+ try {
+ const conn = await db.getConnection();
+ await conn.query('SET FOREIGN_KEY_CHECKS = 1');
+ await db.end();
+ } catch (err) {
+ logger.warn('Error closing database connection pool:', err);
+ }
+});
diff --git a/server/types/pagination.ts b/server/types/pagination.ts
new file mode 100644
index 0000000..7059b1c
--- /dev/null
+++ b/server/types/pagination.ts
@@ -0,0 +1,7 @@
+export interface PaginationResult {
+ page: number;
+ size: number;
+ totalItems: number;
+ totalPages: number;
+ content: T[];
+}