From 22ae9defefb1451897df769d7a646ab6e1004ffd Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Thu, 19 Feb 2026 17:42:31 +0100 Subject: [PATCH 01/17] refactor: network setup --- .env.example | 7 ++++--- client/app/routes/game.tsx | 3 ++- client/app/services/auth.ts | 3 ++- client/package.json | 4 ++-- client/vite.config.ts | 34 +++++++++++++++++++++++++++++++++- server/app.ts | 29 ++++++++++++++++++++++++++--- 6 files changed, 69 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 3f64889..3f14c7e 100644 --- a/.env.example +++ b/.env.example @@ -4,14 +4,15 @@ DB_PASSWORD=app_pw_change_me DB_NAME=red_tetris DB_PORT=3306 -CLIENT_URL=http://localhost:3001,http://127.0.0.1:3001 +CLIENT_URL=http://localhost:3000,http://127.0.0.1:3000 +CLIENT_PORT=3000 # Client Configuration SERVER_PORT=3002 -VITE_SERVER_URL=http://localhost:3002 +VITE_SERVER_URL=auto # JWT Configuration # Generate a strong secret (64 random bytes in hex) with: # node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" JWT_SECRET=c1d841b16e7e5e19675bad6eee8fab760e82e9c9fbb59ef0b6da1b9d82c78b7ca8e850bbeff8c09d3c897f87b9fd8cfb4ab5ef0aa756041f1d736e8362bbbcc9_change_me -JWT_EXPIRES_IN=7d +JWT_EXPIRES_IN=7d \ No newline at end of file diff --git a/client/app/routes/game.tsx b/client/app/routes/game.tsx index 94dda7c..73419e9 100644 --- a/client/app/routes/game.tsx +++ b/client/app/routes/game.tsx @@ -38,7 +38,8 @@ export default function GameRoute() { useEffect(() => { if (socketRef.current) return; // avoid duplicate sockets in StrictMode / HMR - const url = (import.meta.env.VITE_SERVER_URL as string) ?? 'http://localhost:3002'; + const serverPort = import.meta.env.SERVER_PORT || '3002'; + const url = (import.meta.env.VITE_SERVER_URL as string) ?? `http://localhost:${serverPort}`; const token = authService.getToken(); const s = io(url, { autoConnect: false, auth: { token } }) as unknown as Socket; socketRef.current = s; diff --git a/client/app/services/auth.ts b/client/app/services/auth.ts index 960a2a9..5d34a0f 100644 --- a/client/app/services/auth.ts +++ b/client/app/services/auth.ts @@ -1,4 +1,5 @@ -const API_URL = (import.meta.env.VITE_SERVER_URL as string) || 'http://localhost:3002'; +const serverPort = import.meta.env.SERVER_PORT || '3002'; +const API_URL = (import.meta.env.VITE_SERVER_URL as string) || `http://localhost:${serverPort}`; export interface User { id: number; 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..1762998 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -2,5 +2,37 @@ 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/server/app.ts b/server/app.ts index cd1a96b..79141eb 100644 --- a/server/app.ts +++ b/server/app.ts @@ -21,12 +21,35 @@ import { logDbError, logger } from './utils/logger'; 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() { + const os = require('os'); + const interfaces = os.networkInterfaces(); + const ips = []; + + 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 }, }); @@ -167,7 +190,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}`); }); }; From 4d082a032f04e010d3b5e8f528f6eac65bab4122 Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Sun, 22 Feb 2026 19:12:29 +0100 Subject: [PATCH 02/17] Enhance Game and Home Routes with Improved UI and Functionality --- client/.gitignore | 1 + client/app/app.css | 3 +- client/app/components/LoadingOverlay.tsx | 24 + client/app/components/auth/LoginForm.tsx | 153 ++--- client/app/components/auth/ProtectedRoute.tsx | 16 +- .../app/components/auth/PublicOnlyRoute.tsx | 16 +- client/app/components/auth/RegisterForm.tsx | 231 ++++---- client/app/root.tsx | 7 +- client/app/routes/game.tsx | 530 +++++++++++++----- client/app/routes/home.tsx | 2 +- client/app/welcome/welcome.tsx | 185 +++--- 11 files changed, 724 insertions(+), 444 deletions(-) create mode 100644 client/app/components/LoadingOverlay.tsx diff --git a/client/.gitignore b/client/.gitignore index 039ee62..8159bb5 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -5,3 +5,4 @@ # React Router /.react-router/ /build/ +**/+types/ diff --git a/client/app/app.css b/client/app/app.css index 99345d8..47911e5 100644 --- a/client/app/app.css +++ b/client/app/app.css @@ -7,7 +7,8 @@ html, body { - @apply bg-white dark:bg-gray-950; + background: linear-gradient(to bottom right, #581c87, #1e3a8a, #312e81); + min-height: 100vh; @media (prefers-color-scheme: dark) { color-scheme: dark; diff --git a/client/app/components/LoadingOverlay.tsx b/client/app/components/LoadingOverlay.tsx new file mode 100644 index 0000000..f46c1b1 --- /dev/null +++ b/client/app/components/LoadingOverlay.tsx @@ -0,0 +1,24 @@ +export function LoadingOverlay() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

Loading...

+
+
+
+ ); +} diff --git a/client/app/components/auth/LoginForm.tsx b/client/app/components/auth/LoginForm.tsx index 8a88915..4d962ec 100644 --- a/client/app/components/auth/LoginForm.tsx +++ b/client/app/components/auth/LoginForm.tsx @@ -28,85 +28,102 @@ export function LoginForm() { }; return ( -
-
-
-

- Red Tetris -

-

Sign in to your account

+
+
+ {/* Header */} +
+

+ + RED TETRIS + +

+

Welcome Back!

+

Sign in to continue your game

-
{ - void handleSubmit(e); - }} - > - {error && ( -
-
{error}
-
- )} + {/* Login Form */} +
+ { + void handleSubmit(e); + }} + > + {error && ( +
+
{error}
+
+ )} -
-
- - -
-
- - +
+
+ + +
+
+ + +
-
-
-
-
- Don't have an account? - - Register here - -
- +
+

+ Don't have an account?{' '} + + Create one here + +

+
+ +
-
+
); } diff --git a/client/app/components/auth/ProtectedRoute.tsx b/client/app/components/auth/ProtectedRoute.tsx index d0928b1..36c01df 100644 --- a/client/app/components/auth/ProtectedRoute.tsx +++ b/client/app/components/auth/ProtectedRoute.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { Navigate } from 'react-router'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { fetchProfile } from '../../store/authSlice'; +import { LoadingOverlay } from '../LoadingOverlay'; interface ProtectedRouteProps { children: React.ReactNode; @@ -17,14 +18,6 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) { } }, [dispatch, isAuthenticated, user]); - if (isLoading || (isAuthenticated && !user)) { - return ( -
-
Loading...
-
- ); - } - if (!isAuthenticated) { return ( {children}; + return ( + <> + {children} + {(isLoading || (isAuthenticated && !user)) && } + + ); } diff --git a/client/app/components/auth/PublicOnlyRoute.tsx b/client/app/components/auth/PublicOnlyRoute.tsx index b7c892c..410fdca 100644 --- a/client/app/components/auth/PublicOnlyRoute.tsx +++ b/client/app/components/auth/PublicOnlyRoute.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { Navigate } from 'react-router'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { fetchProfile } from '../../store/authSlice'; +import { LoadingOverlay } from '../LoadingOverlay'; interface PublicOnlyRouteProps { children: React.ReactNode; @@ -17,14 +18,6 @@ export function PublicOnlyRoute({ children }: PublicOnlyRouteProps) { } }, [dispatch, isAuthenticated, user]); - if (isLoading || (isAuthenticated && !user)) { - return ( -
-
Loading...
-
- ); - } - if (isAuthenticated) { return ( {children}; + return ( + <> + {children} + {(isLoading || (isAuthenticated && !user)) && } + + ); } diff --git a/client/app/components/auth/RegisterForm.tsx b/client/app/components/auth/RegisterForm.tsx index 89e8254..4d7c0d1 100644 --- a/client/app/components/auth/RegisterForm.tsx +++ b/client/app/components/auth/RegisterForm.tsx @@ -40,124 +40,141 @@ export function RegisterForm() { }; return ( -
-
-
-

- Red Tetris -

-

Create your account

+
+
+ {/* Header */} +
+

+ + RED TETRIS + +

+

Join the Game!

+

Create your account to start playing

-
{ - void handleSubmit(e); - }} - > - {error && ( -
-
{error}
-
- )} + {/* Register Form */} +
+ { + void handleSubmit(e); + }} + > + {error && ( +
+
{error}
+
+ )} -
-
- - -
-
- - -
-
- - -
-
- - +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
-
-
-
- Already have an account? - - Sign in here - -
- +
+

+ Already have an account?{' '} + + Sign in here + +

+
+ +
-
+
); } diff --git a/client/app/root.tsx b/client/app/root.tsx index e28469f..5b1e672 100644 --- a/client/app/root.tsx +++ b/client/app/root.tsx @@ -1,9 +1,10 @@ -import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; +import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from 'react-router'; import { Provider } from 'react-redux'; import type { Route } from './+types/routes'; import './app.css'; import { store } from './store/store'; +import { LoadingOverlay } from './components/LoadingOverlay'; export const links: Route.LinksFunction = () => [ { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, @@ -36,9 +37,13 @@ export function Layout({ children }: { children: React.ReactNode }) { } export default function App() { + const navigation = useNavigation(); + const isNavigating = navigation.state === 'loading'; + return ( + {isNavigating && } ); } diff --git a/client/app/routes/game.tsx b/client/app/routes/game.tsx index 73419e9..1951925 100644 --- a/client/app/routes/game.tsx +++ b/client/app/routes/game.tsx @@ -1,24 +1,30 @@ import { useEffect, useMemo, useRef, useState, type JSX } from 'react'; +import { Link } from 'react-router'; import { io } from 'socket.io-client'; +import type { Route } from './+types/game'; +import { ProtectedRoute } from '../components/auth/ProtectedRoute'; +import { LoadingOverlay } from '../components/LoadingOverlay'; +import { useAppSelector } from '../store/hooks'; import { authService } from '../services/auth'; import type { GameState, Intent, PlayerState, Socket } from '../types/socket'; const WIDTH = 10; const HEIGHT = 20; -const CELL = 16; // smaller cell size for falling pieces +const CELL = 28; -export function meta() { - return [{ title: 'Red Tetris — Play' }]; +export function meta({}: Route.MetaArgs) { + return [{ title: 'Red Tetris — Play' }, { name: 'description', content: 'Play Red Tetris - The Ultimate Multiplayer Tetris Experience!' }]; } -export default function GameRoute() { - const [room, setRoom] = useState('test-room'); - const [name, setName] = useState('alice'); +function GameComponent() { + const { user } = useAppSelector((state) => state.auth); + const [room, setRoom] = useState(''); const [playerId, setPlayerId] = useState(null); const [state, setState] = useState(null); const [hostId, setHostId] = useState(null); const [error, setError] = useState(null); const [message, setMessage] = useState(null); + const [actionLoading, setActionLoading] = useState(false); const socketRef = useRef(null); const computeSpectrumFromBoard = (board: number[][]): number[] => { @@ -48,6 +54,7 @@ export default function GameRoute() { // server may emit either a raw state object or { state, hostId } historically. s.on('state', (payload: GameState | { state: GameState; hostId?: string }) => { + setActionLoading(false); // normalize payload to `state` object const incoming: GameState = 'state' in payload ? payload.state : payload; setState(incoming); @@ -57,6 +64,7 @@ export default function GameRoute() { }); s.on('joined', async (info: { roomId?: string }) => { + setActionLoading(false); setRoom(info.roomId ?? room); try { const profile = await authService.getProfile(); @@ -67,6 +75,7 @@ export default function GameRoute() { setError(null); }); s.on('created', async (info: { roomId?: string }) => { + setActionLoading(false); setRoom(info.roomId ?? room); try { const profile = await authService.getProfile(); @@ -81,9 +90,11 @@ export default function GameRoute() { if (info?.message) setMessage(info.message); }); s.on('restarted', (info: { message?: string }) => { + setActionLoading(false); if (info?.message) setMessage(info.message); }); s.on('restart_failed', (info: { code?: string; message?: string; reason?: string }) => { + setActionLoading(false); setError(info?.message ?? info?.reason ?? 'RESTART_FAILED'); }); s.on('game_over', (info: { message?: string }) => { @@ -95,15 +106,19 @@ export default function GameRoute() { setMessage(info?.message ?? 'YOU WIN'); }); s.on('create_failed', (info: { code?: string; message?: string }) => { + setActionLoading(false); setError(info?.message ?? info?.code ?? 'CREATE_FAILED'); }); s.on('join_failed', (info: { code?: string; message?: string }) => { + setActionLoading(false); setError(info?.message ?? info?.code ?? 'JOIN_FAILED'); }); s.on('start_failed', (info: { code?: string; message?: string }) => { + setActionLoading(false); setError(info?.message ?? info?.code ?? 'START_FAILED'); }); s.on('connect_error', (err: Error | string) => { + setActionLoading(false); if (typeof err === 'string') setError(err); else setError(err?.message ?? String(err)); }); @@ -119,10 +134,22 @@ export default function GameRoute() { const onKey = (e: KeyboardEvent) => { if (!playerId || !socketRef.current) return; const s = socketRef.current; - if (e.key === 'ArrowLeft') s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'left' } }); - if (e.key === 'ArrowRight') s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'right' } }); - if (e.key === 'ArrowUp') s.emit('intent', { roomId: room, intent: { type: 'rotate' } }); - if (e.key === 'ArrowDown') s.emit('intent', { roomId: room, intent: { type: 'soft' } }); + if (e.key === 'ArrowLeft') { + e.preventDefault(); + s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'left' } }); + } + if (e.key === 'ArrowRight') { + e.preventDefault(); + s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'right' } }); + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + s.emit('intent', { roomId: room, intent: { type: 'rotate' } }); + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + s.emit('intent', { roomId: room, intent: { type: 'soft' } }); + } if (e.key === 'Shift') s.emit('intent', { roomId: room, intent: { type: 'hold' } }); if (e.code === 'Space') { e.preventDefault(); @@ -137,6 +164,7 @@ export default function GameRoute() { const s = socketRef.current; if (!s) return; if (!s.connected) s.connect(); + setActionLoading(true); s.emit('join', { roomId: room }); }; @@ -144,6 +172,7 @@ export default function GameRoute() { const s = socketRef.current; if (!s) return; if (!s.connected) s.connect(); + setActionLoading(true); // server uses authenticated user for creation s.emit('create'); setError(null); @@ -156,6 +185,7 @@ export default function GameRoute() { setError('ONLY_HOST_CAN_START'); return; } + setActionLoading(true); s.emit('start', { roomId: room }); }; @@ -194,29 +224,58 @@ export default function GameRoute() { const renderCell = (x: number, y: number) => { const val = myView?.board?.[y]?.[x] ?? 0; - const color = - val == 0 - ? 'bg-transparent' - : val == -2 - ? 'bg-[rgba(255,255,255,0.06)]' - : val == -1 - ? 'bg-gray-800' - : [ - 'bg-red-200', - 'bg-green-200', - 'bg-blue-200', - 'bg-yellow-200', - 'bg-purple-200', - 'bg-pink-200', - 'bg-orange-200', - 'bg-gray-200', - 'bg-white-200', - ][val - 1]; + if (val === 0) { + return ( +
+ ); + } + if (val === -2) { + return ( +
+ ); + } + if (val === -1) { + const penalty = '#4b5563'; + return ( +
+ ); + } + const color = PIECE_ID_COLORS[val] ?? '#999'; return (
); }; @@ -316,11 +375,26 @@ export default function GameRoute() { else if (val === -1) bg = isAlive ? '#111' : '#444'; else bg = isAlive ? (PIECE_ID_COLORS[val] ?? '#999') : '#777'; + const isPiece = val > 0; cells.push( -
+ isPiece && isAlive ? ( +
+ ) : ( +
+ ) ); } } @@ -340,12 +414,29 @@ export default function GameRoute() { for (let r = 0; r < gridSize; r++) { for (let c = 0; c < gridSize; c++) { const inMat = Boolean(mat && r < mat.length && c < mat[0].length && mat[r][c]); - const bg = inMat ? color : 'transparent'; cells.push( -
+ inMat ? ( +
+ ) : ( +
+ ) ); } } @@ -355,144 +446,279 @@ export default function GameRoute() { }; return ( -
-

Red Tetris — Client

- -
-
- - setRoom(e.target.value)} - /> -
-
- - setName(e.target.value)} - /> -
-
- - - - {state?.status === 'finished' && playerId && hostId && playerId === hostId && ( - - )} -
- {hostId && playerId === hostId && ( -
- Passcode: {room} + ← Back + +
+

+ + RED TETRIS + +

+

Play Mode

- )} - {hostName && ( -
- Host: {hostName} {hostId === playerId || state?.hostId === playerId ? '(you)' : ''} -
- )} - {error &&
Error: {error}
} - {message &&
Message: {message}
} -
+ -
-
-
-
- {(function rows() { - const cells: JSX.Element[] = []; - for (let y = 0; y < HEIGHT; y++) { - for (let x = 0; x < WIDTH; x++) cells.push(renderCell(x, y)); - } - return cells; - })()} + {/* Room Controls */} +
+

Room Settings

+ +
+
+ + setRoom(e.target.value)} + className='w-full px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:border-transparent' + placeholder='Room code' + />
-
-
- Score: {myView?.score ?? 0} -
-
-
+ +
- - + {state?.status === 'finished' && playerId && hostId && playerId === hostId && ( + + )}
-
-
- Hold -
{renderNextPreview(myView?.holdPiece ?? null, 14)}
-
-
- Next -
{renderNextPreview(myView?.nextPiece ?? null, 14)}
-
+ + {/* Status Messages */} +
+ {hostId && playerId === hostId && ( +
+ 🔑 Room: + {room} +
+ )} + {hostName && ( +
+ 👑 Host: + {hostName} + {hostId === playerId || state?.hostId === playerId ? ( + (you) + ) : null} +
+ )} + {error && ( +
+ + {error} +
+ )} + {message && ( +
+ + {message} +
+ )}
-
-

Opponents

-
- {opponents.map((op) => ( -
-
-
-
{op.name}
-
Score: {op.score}
+ {/* Main Game Layout */} +
+ {/* Left Side: Room Controls (Desktop) */} +
+
+

Room Settings

+ +
+
+ + setRoom(e.target.value)} + className='w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:border-transparent' + placeholder='Enter room code' + /> +
+ +
+ + + + {state?.status === 'finished' && playerId && hostId && playerId === hostId && ( + + )} +
+ + {/* Status Messages */} +
+ {hostId && playerId === hostId && ( +
+ 🔑 Room Code: + {room} +
+ )} + {hostName && ( +
+ 👑 Host: + {hostName} + {hostId === playerId || state?.hostId === playerId ? ( + (you) + ) : null} +
+ )} + {error && ( +
+ ❌ Error: + {error} +
+ )} + {message && ( +
+ + {message} +
+ )} +
+
+
+
+ + {/* Center: Game Area */} +
+ {/* Hold Piece */} +
+

Hold

+
+ {renderNextPreview(myView?.holdPiece ?? null, 18)} +
+
+ + {/* Main Game Board */} +
+

Your Board

+ +
+
+
+ {(function rows() { + const cells: JSX.Element[] = []; + for (let y = 0; y < HEIGHT; y++) { + for (let x = 0; x < WIDTH; x++) cells.push(renderCell(x, y)); + } + return cells; + })()}
-
- {op.isAlive ? 'alive' : 'dead'} +
+ +
+
+ {myView?.score ?? 0} +
Score
-
{renderOpponentBoard(op.board ?? null, op.isAlive, 3)}
- ))} +
+ + {/* Next Piece */} +
+

Next

+
+ {renderNextPreview(myView?.nextPiece ?? null, 18)} +
+
+ + {/* Right Side: Opponents */} + {opponents.length > 0 && ( +
+
+

Opponents

+
+ {opponents.map((op) => ( +
+ {/* Left: Info */} +
+
+
+ {op.name} +
+ {op.isHost && ( + 👑 Host + )} +
+ Score: {op.score} +
+
+ {op.isAlive ? 'ALIVE' : 'DEAD'} +
+
+ {/* Right: Board */} +
+ {renderOpponentBoard(op.board ?? null, op.isAlive, 5)} +
+
+ ))} +
+
+
+ )}
- -
- Raw state -
{JSON.stringify(state, null, 2)}
-
+ {actionLoading && }
); } + +export default function GameRoute() { + return ( + + + + ); +} diff --git a/client/app/routes/home.tsx b/client/app/routes/home.tsx index a4c26a4..e1bbc91 100644 --- a/client/app/routes/home.tsx +++ b/client/app/routes/home.tsx @@ -3,7 +3,7 @@ import { Welcome } from '../welcome/welcome'; import { ProtectedRoute } from '../components/auth/ProtectedRoute'; export function meta({}: Route.MetaArgs) { - return [{ title: 'New React Router App' }, { name: 'description', content: 'Welcome to React Router!' }]; + return [{ title: 'Red Tetris - Home' }, { name: 'description', content: 'Play Red Tetris - The Ultimate Multiplayer Tetris Experience!' }]; } export default function Home() { diff --git a/client/app/welcome/welcome.tsx b/client/app/welcome/welcome.tsx index 7e80c67..4d14076 100644 --- a/client/app/welcome/welcome.tsx +++ b/client/app/welcome/welcome.tsx @@ -1,7 +1,6 @@ import { useAppDispatch, useAppSelector } from '../store/hooks'; import { logoutUser } from '../store/authSlice'; -import logoDark from './logo-dark.svg'; -import logoLight from './logo-light.svg'; +import { Link } from 'react-router'; export function Welcome() { const dispatch = useAppDispatch(); @@ -11,107 +10,101 @@ export function Welcome() { void dispatch(logoutUser()); }; + // Mock stats for now - these would come from the backend later + const userStats = { + gamesPlayed: 42, + highScore: 15420, + bestCombo: 8, + averageScore: 7830, + winRate: 68, + totalPlayTime: '24h 15m' + }; + return ( -
-
-
-
- React Router - React Router -
+
+
+ {/* Header */} +
+

+ + RED TETRIS + +

+

The Ultimate Multiplayer Tetris Experience

-
- {user && ( -
-

- Welcome, {user.username}! -

-
-

- Email: {user.email} -

-

- Member since: {new Date(user.created_at).toLocaleDateString()} -

+ + {user && ( +
+ {/* Profile Section - Above play button on all devices */} +
+
+
+
+ + {user.username.charAt(0).toUpperCase()} + +
+

{user.username}

+

{user.email}

+

+ Member since {new Date(user.created_at).toLocaleDateString()} +

+
+ +
- + 🎮 PLAY NOW + +

Ready to drop some blocks?

- )} - -
+ + {/* Stats Grid */} +
+

Your Stats

+
+
+
{userStats.gamesPlayed}
+
Games Played
+
+
+
{userStats.highScore.toLocaleString()}
+
High Score
+
+
+
{userStats.bestCombo}x
+
Best Combo
+
+
+
{userStats.averageScore.toLocaleString()}
+
Avg Score
+
+
+
{userStats.winRate}%
+
Win Rate
+
+
+
{userStats.totalPlayTime}
+
Play Time
+
+
+
+
+ )}
); } -const resources = [ - { - href: 'https://reactrouter.com/docs', - text: 'React Router Docs', - icon: ( - - - - ), - }, - { - href: 'https://rmx.as/discord', - text: 'Join Discord', - icon: ( - - - - ), - }, -]; From 2bf3567dc6e37c4502b2c1ad3504e3753b05e11a Mon Sep 17 00:00:00 2001 From: LuckyIntegral Date: Sun, 22 Feb 2026 23:44:09 +0000 Subject: [PATCH 03/17] feat: add endpoints for fetching user's games --- server/app.ts | 3 + server/controllers/gamesController.ts | 68 +++++++++++++++++++ server/core/GameService.ts | 47 +++++++++++++ server/core/Player.ts | 1 + server/game/GameEngine.ts | 6 +- server/game/types.ts | 1 + .../002_create_games_and_records.sql | 23 +++++++ server/models/Game.ts | 25 +++++++ server/net/socketHandlers.ts | 9 ++- server/routes/games.ts | 13 ++++ 10 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 server/controllers/gamesController.ts create mode 100644 server/core/GameService.ts create mode 100644 server/migrations/002_create_games_and_records.sql create mode 100644 server/models/Game.ts create mode 100644 server/routes/games.ts diff --git a/server/app.ts b/server/app.ts index cd1a96b..bcf1151 100644 --- a/server/app.ts +++ b/server/app.ts @@ -13,6 +13,7 @@ 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'; @@ -131,6 +132,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) => { diff --git a/server/controllers/gamesController.ts b/server/controllers/gamesController.ts new file mode 100644 index 0000000..4725ca7 --- /dev/null +++ b/server/controllers/gamesController.ts @@ -0,0 +1,68 @@ +import { Response } from 'express'; +import db from '../db'; +import { AuthRequest } from '../middleware/auth'; +import { GameDTO, GameRow } from '../models/Game'; +import { logDbError } from '../utils/logger'; + +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 [rows] = await db.query( + `SELECT g.id AS game_id, + g.started_at AS started_at, + g.finished_at AS finished_at, + gr.player_id AS player_id, + u.username AS player_username, + u.email AS player_email, + 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, + started_at: r.started_at, + finished_at: r.finished_at, + players: [], + }); + } + + const record: GameDTO = gamesMap.get(r.game_id)!; + + gamesMap.set(r.game_id, { + ...record, + players: [ + ...record.players, + { + player: { id: r.player_id, username: r.username as string, email: r.email as string }, + score: r.score, + place: r.place, + }, + ], + }); + } + + const games = Array.from(gamesMap.values()); + + res.json({ success: true, data: games }); + } 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..b6f0dcd --- /dev/null +++ b/server/core/GameService.ts @@ -0,0 +1,47 @@ +import { ResultSetHeader } from 'mysql2'; +import db from '../db'; +import { Game } from '../game/Game'; +import { logger } from '../utils/logger'; + +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]); + logger.info('Inserted game with info on insert:', res); + + 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/game/GameEngine.ts b/server/game/GameEngine.ts index 7d95539..b21a8fb 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; @@ -351,6 +354,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..d88b5c8 --- /dev/null +++ b/server/migrations/002_create_games_and_records.sql @@ -0,0 +1,23 @@ +-- Create games table +CREATE TABLE IF NOT EXISTS games ( + id INT AUTO_INCREMENT PRIMARY KEY, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_started_at (started_at) +) 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..df97403 --- /dev/null +++ b/server/models/Game.ts @@ -0,0 +1,25 @@ +import { RowDataPacket } from 'mysql2'; + +export interface GameRow extends RowDataPacket { + game_id: number; + started_at: Date; + finished_at: Date; + player_id: number; + player_username: string; + player_email: string; + score: number; + place: number; +} + +export interface GameRecordDto { + player: { id: number; username: string; email: string }; + score: number; + place: number; +} + +export interface GameDTO { + game_id: number; + started_at: Date; + finished_at: Date; + players: GameRecordDto[]; +} diff --git a/server/net/socketHandlers.ts b/server/net/socketHandlers.ts index 48d8b48..d53ec0d 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, @@ -39,6 +40,7 @@ export function registerSocketHandlers( piecesPlaced: playerState.piecesPlaced, score: playerState.score, isAlive: playerState.isAlive, + diedAt: playerState.diedAt, name: playerMeta?.name ?? playerState.name, }; } @@ -230,6 +232,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/routes/games.ts b/server/routes/games.ts new file mode 100644 index 0000000..537de8d --- /dev/null +++ b/server/routes/games.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { getUserGames } from '../controllers/gamesController'; +import { authenticate } from '../middleware/auth'; + +const router = Router(); + +/** + * GET /api/games + * Returns games the authenticated user participated in. + */ +router.get('/', authenticate, getUserGames); + +export default router; From 51c9cea1b5b7afd7392d9bad01394b0e1c1e829a Mon Sep 17 00:00:00 2001 From: LuckyIntegral Date: Sun, 22 Feb 2026 23:46:29 +0000 Subject: [PATCH 04/17] docs: add swagger to GET /games endpoint --- server/routes/games.ts | 56 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/server/routes/games.ts b/server/routes/games.ts index 537de8d..8ce8642 100644 --- a/server/routes/games.ts +++ b/server/routes/games.ts @@ -5,8 +5,60 @@ import { authenticate } from '../middleware/auth'; const router = Router(); /** - * GET /api/games - * Returns games the authenticated user participated in. + * @swagger + * /api/games: + * get: + * summary: Get games for authenticated user + * tags: [Games] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: A list of games the user participated in + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * started_at: + * type: string + * format: date-time + * finished_at: + * type: string + * format: date-time + * players: + * type: array + * items: + * type: object + * properties: + * player_id: + * type: integer + * username: + * type: string + * score: + * 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); From 46f0fe5c8e9b32197e1b08702dfa28b9498355c5 Mon Sep 17 00:00:00 2001 From: LuckyIntegral Date: Mon, 23 Feb 2026 00:15:47 +0000 Subject: [PATCH 05/17] feat: add pagination --- server/controllers/gamesController.ts | 25 +++++++- server/routes/games.ts | 83 +++++++++++++++++++-------- server/routes/pagination.ts | 15 +++++ server/types/pagination.ts | 7 +++ 4 files changed, 103 insertions(+), 27 deletions(-) create mode 100644 server/routes/pagination.ts create mode 100644 server/types/pagination.ts diff --git a/server/controllers/gamesController.ts b/server/controllers/gamesController.ts index 4725ca7..5f5466f 100644 --- a/server/controllers/gamesController.ts +++ b/server/controllers/gamesController.ts @@ -2,8 +2,11 @@ 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) { @@ -12,6 +15,22 @@ export const getUserGames = async (req: AuthRequest, res: Response): Promise= 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.started_at AS started_at, @@ -48,7 +67,7 @@ export const getUserGames = async (req: AuthRequest, res: Response): Promise b.game_id - a.game_id); - res.json({ success: true, data: games }); + 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' } }); diff --git a/server/routes/games.ts b/server/routes/games.ts index 8ce8642..63dbca5 100644 --- a/server/routes/games.ts +++ b/server/routes/games.ts @@ -12,9 +12,22 @@ const router = Router(); * 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: A list of games the user participated in + * description: Paginated games the user participated in * content: * application/json: * schema: @@ -24,29 +37,51 @@ const router = Router(); * type: boolean * example: true * data: - * type: array - * items: - * type: object - * properties: - * id: - * type: integer - * started_at: - * type: string - * format: date-time - * finished_at: - * type: string - * format: date-time - * players: - * type: array - * items: - * type: object - * properties: - * player_id: - * type: integer - * username: - * type: string - * score: - * type: integer + * 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 + * started_at: + * type: string + * format: date-time + * finished_at: + * type: string + * format: date-time + * players: + * type: array + * items: + * type: object + * properties: + * player: + * type: object + * properties: + * id: + * type: integer + * username: + * type: string + * email: + * type: string + * score: + * type: integer + * place: + * type: integer * 401: * description: Unauthorized - No or invalid token * content: 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/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[]; +} From 6b4bc800ed4944c6b013afcf69abd4517e0d5c49 Mon Sep 17 00:00:00 2001 From: LuckyIntegral Date: Mon, 23 Feb 2026 07:54:34 +0000 Subject: [PATCH 06/17] chore: adjust path to .env --- server/app.ts | 4 ++-- server/db.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/server/app.ts b/server/app.ts index bcf1151..249af7f 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'; @@ -19,6 +17,8 @@ import { ClientToServerEvents, ServerToClientEvents, SocketData } from './types/ import { verifyToken } from './utils/jwt'; import { logDbError, logger } from './utils/logger'; +dotenv.config({ path: path.resolve(process.cwd(), '..', '.env') }); + const app = express(); const server = http.createServer(app); diff --git a/server/db.ts b/server/db.ts index e3bf3d3..434f4e5 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,8 +1,12 @@ +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') }); + const dbPortRaw = process.env.DB_PORT ?? '3306'; const dbPort = Number.parseInt(dbPortRaw, 10); if (Number.isNaN(dbPort)) { From e056c52cc9b128aff13d3d1d4c2d8298ce4ef726 Mon Sep 17 00:00:00 2001 From: LuckyIntegral Date: Mon, 23 Feb 2026 08:32:54 +0000 Subject: [PATCH 07/17] test: use test connection for tests --- .devcontainer/start-dev.sh | 5 ++++- .env.example | 3 +++ server/core/GameService.ts | 2 -- server/db.ts | 23 +++++++++++++++++++++-- server/package.json | 2 +- server/tests/setup.test.ts | 35 +++++++++++++++++++++++++++++++++++ 6 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 server/tests/setup.test.ts diff --git a/.devcontainer/start-dev.sh b/.devcontainer/start-dev.sh index e66e1aa..5214e04 100644 --- a/.devcontainer/start-dev.sh +++ b/.devcontainer/start-dev.sh @@ -8,9 +8,12 @@ done echo "MySQL is ready!" -mysql -e "CREATE DATABASE IF NOT EXISTS red_tetris;" mysql -e "CREATE USER IF NOT EXISTS 'app'@'%' IDENTIFIED BY 'app_pw_change_me';" +mysql -e "CREATE DATABASE IF NOT EXISTS red_tetris;" mysql -e "GRANT ALL PRIVILEGES ON red_tetris.* TO 'app'@'%';" +mysql -e "CREATE DATABASE IF NOT EXISTS red_tetris_test;" +mysql -e "GRANT ALL PRIVILEGES ON red_tetris_test.* TO 'app'@'%';" + mysql -e "FLUSH PRIVILEGES;" echo "Done!" diff --git a/.env.example b/.env.example index 3f64889..2492391 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ DB_PASSWORD=app_pw_change_me DB_NAME=red_tetris DB_PORT=3306 +DB_PASSWORD_TEST=app_pw_change_me +DB_NAME_TEST=red_tetris_test + CLIENT_URL=http://localhost:3001,http://127.0.0.1:3001 # Client Configuration diff --git a/server/core/GameService.ts b/server/core/GameService.ts index b6f0dcd..0f05c20 100644 --- a/server/core/GameService.ts +++ b/server/core/GameService.ts @@ -1,7 +1,6 @@ import { ResultSetHeader } from 'mysql2'; import db from '../db'; import { Game } from '../game/Game'; -import { logger } from '../utils/logger'; export class GameService { async saveFinishedGame(game: Game): Promise { @@ -11,7 +10,6 @@ export class GameService { const finishedAt = new Date(); const [res] = await conn.query('INSERT INTO games (finished_at) VALUES (?)', [finishedAt]); - logger.info('Inserted game with info on insert:', res); const gameId = res.insertId; diff --git a/server/db.ts b/server/db.ts index 434f4e5..cd95d51 100644 --- a/server/db.ts +++ b/server/db.ts @@ -7,22 +7,41 @@ 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.`); } -const dbPassword = process.env.DB_PASSWORD; +let dbPassword; +if (process.env.NODE_ENV === 'test') { + dbPassword = process.env.DB_PASSWORD_TEST; +} else { + dbPassword = process.env.DB_PASSWORD; +} + if (!dbPassword) { throw new Error('DB_PASSWORD environment variable is required for database connection'); } +let dbName; +if (process.env.NODE_ENV === 'test') { + dbName = process.env.DB_NAME_TEST; +} else { + dbName = process.env.DB_NAME; +} + +if (!dbName) { + throw new Error('DB_NAME environment variable is required for database connection'); +} + const pool = mysql.createPool({ host: process.env.DB_HOST ?? 'localhost', user: process.env.DB_USER ?? 'app', password: dbPassword, - database: process.env.DB_NAME ?? 'red_tetris', + database: dbName, port: dbPort, waitForConnections: true, connectionLimit: 10, diff --git a/server/package.json b/server/package.json index 3965aef..1a5f02b 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": [], diff --git a/server/tests/setup.test.ts b/server/tests/setup.test.ts new file mode 100644 index 0000000..a79179a --- /dev/null +++ b/server/tests/setup.test.ts @@ -0,0 +1,35 @@ +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 } from '../db'; +import { logger } from '../utils/logger'; + +before(async function () { + logger.debug('Setting up test database connection and running migrations'); + 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 { + await db.end(); + } catch (err) { + logger.warn('Error closing database connection pool:', err); + } +}); From 1839930c275d3c2d24199719d852607650f68d30 Mon Sep 17 00:00:00 2001 From: LuckyIntegral Date: Mon, 23 Feb 2026 09:30:06 +0000 Subject: [PATCH 08/17] test: add 50% branch coverage --- pnpm-lock.yaml | 50 ++++++++++++++++++++++++++++++++++++++ server/db.ts | 41 +++++++++---------------------- server/package.json | 10 +++++--- server/tests/db.test.ts | 44 +++++++++++++++++++++++++++++++++ server/tests/setup.test.ts | 3 ++- 5 files changed, 113 insertions(+), 35 deletions(-) create mode 100644 server/tests/db.test.ts 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/db.ts b/server/db.ts index cd95d51..973a060 100644 --- a/server/db.ts +++ b/server/db.ts @@ -10,36 +10,22 @@ 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; -if (process.env.NODE_ENV === 'test') { - dbPassword = process.env.DB_PASSWORD_TEST; -} else { - dbPassword = process.env.DB_PASSWORD; -} +let dbPassword, dbName; -if (!dbPassword) { - throw new Error('DB_PASSWORD environment variable is required for database connection'); -} - -let dbName; if (process.env.NODE_ENV === 'test') { - dbName = process.env.DB_NAME_TEST; + dbPassword = process.env.DB_PASSWORD_TEST ?? 'app_change_me'; + dbName = process.env.DB_NAME_TEST ?? 'red_tetris_test'; } else { - dbName = process.env.DB_NAME; -} - -if (!dbName) { - throw new Error('DB_NAME environment variable is required for database connection'); + 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: dbName, port: dbPort, @@ -55,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; } }; @@ -117,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/package.json b/server/package.json index 1a5f02b..2c9506c 100644 --- a/server/package.json +++ b/server/package.json @@ -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/tests/db.test.ts b/server/tests/db.test.ts new file mode 100644 index 0000000..3fa993a --- /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 }) as DBModule; + 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 }) as DBModule; + await db.runMigrations(); + sinon.assert.called(fakeConn.query); + }); +}); diff --git a/server/tests/setup.test.ts b/server/tests/setup.test.ts index a79179a..8858252 100644 --- a/server/tests/setup.test.ts +++ b/server/tests/setup.test.ts @@ -5,11 +5,12 @@ import path from 'path'; process.env.NODE_ENV = process.env.NODE_ENV ?? 'test'; dotenv.config({ path: path.resolve(process.cwd(), '.env.test') }); -import db, { runMigrations } from '../db'; +import db, { runMigrations, testConnection } from '../db'; import { logger } from '../utils/logger'; before(async function () { logger.debug('Setting up test database connection and running migrations'); + await testConnection(); await runMigrations(); }); From 130daef5adb8a5ab267532c215de0978a782ccb9 Mon Sep 17 00:00:00 2001 From: LuckyIntegral Date: Mon, 23 Feb 2026 20:20:34 +0000 Subject: [PATCH 09/17] chore: delete start_at field --- server/controllers/gamesController.ts | 2 -- server/migrations/002_create_games_and_records.sql | 2 -- server/models/Game.ts | 2 -- server/routes/games.ts | 3 --- 4 files changed, 9 deletions(-) diff --git a/server/controllers/gamesController.ts b/server/controllers/gamesController.ts index 5f5466f..179a2be 100644 --- a/server/controllers/gamesController.ts +++ b/server/controllers/gamesController.ts @@ -33,7 +33,6 @@ export const getUserGames = async (req: AuthRequest, res: Response): Promise( `SELECT g.id AS game_id, - g.started_at AS started_at, g.finished_at AS finished_at, gr.player_id AS player_id, u.username AS player_username, @@ -54,7 +53,6 @@ export const getUserGames = async (req: AuthRequest, res: Response): Promise Date: Mon, 23 Feb 2026 20:25:22 +0000 Subject: [PATCH 10/17] chore: hide user emails --- server/controllers/gamesController.ts | 13 ++----------- server/migrations/002_create_games_and_records.sql | 2 +- server/models/Game.ts | 3 +-- server/routes/games.ts | 2 -- 4 files changed, 4 insertions(+), 16 deletions(-) diff --git a/server/controllers/gamesController.ts b/server/controllers/gamesController.ts index 179a2be..2ba39ae 100644 --- a/server/controllers/gamesController.ts +++ b/server/controllers/gamesController.ts @@ -36,7 +36,6 @@ export const getUserGames = async (req: AuthRequest, res: Response): Promise Date: Mon, 23 Feb 2026 20:33:43 +0000 Subject: [PATCH 11/17] test: add error handling on testConnection fail --- server/tests/setup.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/tests/setup.test.ts b/server/tests/setup.test.ts index 8858252..1823fdf 100644 --- a/server/tests/setup.test.ts +++ b/server/tests/setup.test.ts @@ -10,7 +10,11 @@ import { logger } from '../utils/logger'; before(async function () { logger.debug('Setting up test database connection and running migrations'); - await testConnection(); + const connectionOk = await testConnection(); + if (connectionOk === false) { + logger.error('Test database connection failed'); + throw new Error('Failed to connect to test database'); + } await runMigrations(); }); @@ -29,6 +33,8 @@ afterEach(async () => { 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); From 784d3f7aefaa7907fbc1e93a72187226a176af00 Mon Sep 17 00:00:00 2001 From: LuckyIntegral Date: Mon, 23 Feb 2026 20:35:35 +0000 Subject: [PATCH 12/17] perf: push directly to players array, instead of recreating the entire structure --- server/controllers/gamesController.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/server/controllers/gamesController.ts b/server/controllers/gamesController.ts index 2ba39ae..9b958c5 100644 --- a/server/controllers/gamesController.ts +++ b/server/controllers/gamesController.ts @@ -54,14 +54,7 @@ export const getUserGames = async (req: AuthRequest, res: Response): Promise b.game_id - a.game_id); From f57da04977310637f5bd9d27c834d7fdc2d58f8f Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Tue, 24 Feb 2026 09:52:21 +0100 Subject: [PATCH 13/17] feat: implement games service and integrate user stats in Welcome component --- client/app/services/games.ts | 96 +++++++++++++++++++++++ client/app/welcome/welcome.tsx | 135 +++++++++++++++++++++++++-------- 2 files changed, 198 insertions(+), 33 deletions(-) create mode 100644 client/app/services/games.ts diff --git a/client/app/services/games.ts b/client/app/services/games.ts new file mode 100644 index 0000000..b55d6f2 --- /dev/null +++ b/client/app/services/games.ts @@ -0,0 +1,96 @@ +import { authService } from './auth'; + +const API_URL = (import.meta.env.VITE_SERVER_URL as string) || 'http://localhost:3002'; + +export interface GamePlayer { + player: { id: number; username: string }; + score: number; + place: number; +} + +export interface Game { + game_id: number; + finished_at: string; + players: GamePlayer[]; +} + +export interface PaginatedGames { + page: number; + size: number; + totalItems: number; + totalPages: number; + content: Game[]; +} + +export interface UserStats { + gamesPlayed: number; + wins: number; + winRate: number; + highScore: number; + averageScore: number; + bestPlace: number; +} + +const createGamesService = () => { + const getGames = async (page = 0, size = 10): Promise => { + const token = authService.getToken(); + if (!token) throw new Error('No authentication token'); + + const response = await fetch(`${API_URL}/api/games?page=${page}&size=${size}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error?.message ?? 'Failed to fetch games'); + } + + return result.data as PaginatedGames; + }; + + const getAllGames = async (): Promise => { + const firstPage = await getGames(0, 100); + const all = [...firstPage.content]; + + for (let p = 1; p < firstPage.totalPages; p++) { + const page = await getGames(p, 100); + all.push(...page.content); + } + + return all; + }; + + const computeStats = (games: Game[], userId: number): UserStats => { + if (games.length === 0) { + return { gamesPlayed: 0, wins: 0, winRate: 0, highScore: 0, averageScore: 0, bestPlace: 0 }; + } + + let wins = 0; + let highScore = 0; + let totalScore = 0; + let bestPlace = Infinity; + + for (const game of games) { + const myRecord = game.players.find((p) => p.player.id === userId); + if (!myRecord) continue; + + totalScore += myRecord.score; + if (myRecord.score > highScore) highScore = myRecord.score; + if (myRecord.place < bestPlace) bestPlace = myRecord.place; + if (myRecord.place === 1) wins++; + } + + return { + gamesPlayed: games.length, + wins, + winRate: games.length > 0 ? Math.round((wins / games.length) * 100) : 0, + highScore, + averageScore: games.length > 0 ? Math.round(totalScore / games.length) : 0, + bestPlace: bestPlace === Infinity ? 0 : bestPlace, + }; + }; + + return { getGames, getAllGames, computeStats }; +}; + +export const gamesService = createGamesService(); diff --git a/client/app/welcome/welcome.tsx b/client/app/welcome/welcome.tsx index 4d14076..0458ef6 100644 --- a/client/app/welcome/welcome.tsx +++ b/client/app/welcome/welcome.tsx @@ -1,25 +1,44 @@ +import { useEffect, useState } from 'react'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { logoutUser } from '../store/authSlice'; import { Link } from 'react-router'; +import { gamesService, type Game, type UserStats } from '../services/games'; export function Welcome() { const dispatch = useAppDispatch(); const { user } = useAppSelector((state) => state.auth); + const [stats, setStats] = useState(null); + const [recentGames, setRecentGames] = useState([]); + const [statsLoading, setStatsLoading] = useState(true); + + useEffect(() => { + if (!user) return; + let cancelled = false; + + const loadStats = async () => { + try { + setStatsLoading(true); + const allGames = await gamesService.getAllGames(); + if (cancelled) return; + const computed = gamesService.computeStats(allGames, user.id); + setStats(computed); + setRecentGames(allGames.slice(0, 5)); + } catch (err) { + console.warn('Failed to load game stats', err); + if (!cancelled) setStats({ gamesPlayed: 0, wins: 0, winRate: 0, highScore: 0, averageScore: 0, bestPlace: 0 }); + } finally { + if (!cancelled) setStatsLoading(false); + } + }; + + void loadStats(); + return () => { cancelled = true; }; + }, [user]); const handleLogout = () => { void dispatch(logoutUser()); }; - // Mock stats for now - these would come from the backend later - const userStats = { - gamesPlayed: 42, - highScore: 15420, - bestCombo: 8, - averageScore: 7830, - winRate: 68, - totalPlayTime: '24h 15m' - }; - return (
@@ -35,7 +54,7 @@ export function Welcome() { {user && (
- {/* Profile Section - Above play button on all devices */} + {/* Profile Section */}
@@ -74,33 +93,83 @@ export function Welcome() { {/* Stats Grid */}

Your Stats

-
-
-
{userStats.gamesPlayed}
-
Games Played
+ {statsLoading ? ( +
+
+

Loading stats...

-
-
{userStats.highScore.toLocaleString()}
-
High Score
-
-
-
{userStats.bestCombo}x
-
Best Combo
-
-
-
{userStats.averageScore.toLocaleString()}
-
Avg Score
+ ) : stats && stats.gamesPlayed > 0 ? ( +
+
+
{stats.gamesPlayed}
+
Games Played
+
+
+
{stats.highScore.toLocaleString()}
+
High Score
+
+
+
{stats.wins}
+
Wins
+
+
+
{stats.averageScore.toLocaleString()}
+
Avg Score
+
+
+
{stats.winRate}%
+
Win Rate
+
+
+
#{stats.bestPlace}
+
Best Place
+
-
-
{userStats.winRate}%
-
Win Rate
+ ) : ( +
+

No games played yet

+

Play your first game to see your stats!

-
-
{userStats.totalPlayTime}
-
Play Time
+ )} +
+ + {/* 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
+
+
+ ); + })}
-
+ )}
)}
From 2e5e3c4b9d4a091c2bbe1c17dde7e8e50f193e7e Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Tue, 24 Feb 2026 10:47:42 +0100 Subject: [PATCH 14/17] fix: update CLIENT_PORT in .env.example and adjust socket connection URL in GameComponent --- .env.example | 2 +- client/app/routes/game.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 60e80fe..5ea5f59 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ DB_PASSWORD_TEST=app_pw_change_me DB_NAME_TEST=red_tetris_test CLIENT_URL=http://localhost:3000,http://127.0.0.1:3000 -CLIENT_PORT=3000 +CLIENT_PORT=3001 # Client Configuration SERVER_PORT=3002 diff --git a/client/app/routes/game.tsx b/client/app/routes/game.tsx index 1951925..4902a93 100644 --- a/client/app/routes/game.tsx +++ b/client/app/routes/game.tsx @@ -44,8 +44,7 @@ function GameComponent() { useEffect(() => { if (socketRef.current) return; // avoid duplicate sockets in StrictMode / HMR - const serverPort = import.meta.env.SERVER_PORT || '3002'; - const url = (import.meta.env.VITE_SERVER_URL as string) ?? `http://localhost:${serverPort}`; + const url = (import.meta.env.VITE_SERVER_URL as string) ?? 'http://localhost:3002'; const token = authService.getToken(); const s = io(url, { autoConnect: false, auth: { token } }) as unknown as Socket; socketRef.current = s; From 65e368727528a11b8d4bcaaca298ee2eee8b8fa4 Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Tue, 24 Feb 2026 10:56:23 +0100 Subject: [PATCH 15/17] style: improve code formatting and consistency across components and services --- client/app/components/LoadingOverlay.tsx | 45 +++++++-- client/app/components/auth/LoginForm.tsx | 22 ++++- client/app/components/auth/RegisterForm.tsx | 22 ++++- client/app/routes/game.tsx | 95 ++++++++++++------- client/app/routes/home.tsx | 5 +- client/app/services/auth.ts | 3 +- client/app/services/games.ts | 4 +- client/app/welcome/welcome.tsx | 33 ++++--- client/vite.config.ts | 23 +++-- server/app.ts | 15 +-- .../002_create_games_and_records.sql | 2 +- server/tests/db.test.ts | 4 +- 12 files changed, 184 insertions(+), 89 deletions(-) diff --git a/client/app/components/LoadingOverlay.tsx b/client/app/components/LoadingOverlay.tsx index f46c1b1..59444d5 100644 --- a/client/app/components/LoadingOverlay.tsx +++ b/client/app/components/LoadingOverlay.tsx @@ -5,15 +5,42 @@ export function LoadingOverlay() {
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+

Loading...

diff --git a/client/app/components/auth/LoginForm.tsx b/client/app/components/auth/LoginForm.tsx index 4d962ec..3cb630a 100644 --- a/client/app/components/auth/LoginForm.tsx +++ b/client/app/components/auth/LoginForm.tsx @@ -99,9 +99,25 @@ export function LoginForm() { > {isLoading ? ( - - - + + + Signing in... diff --git a/client/app/components/auth/RegisterForm.tsx b/client/app/components/auth/RegisterForm.tsx index 4d7c0d1..b49a44a 100644 --- a/client/app/components/auth/RegisterForm.tsx +++ b/client/app/components/auth/RegisterForm.tsx @@ -150,9 +150,25 @@ export function RegisterForm() { > {isLoading ? ( - - - + + + Creating account... diff --git a/client/app/routes/game.tsx b/client/app/routes/game.tsx index 4902a93..1beaece 100644 --- a/client/app/routes/game.tsx +++ b/client/app/routes/game.tsx @@ -4,20 +4,21 @@ import { io } from 'socket.io-client'; import type { Route } from './+types/game'; import { ProtectedRoute } from '../components/auth/ProtectedRoute'; import { LoadingOverlay } from '../components/LoadingOverlay'; -import { useAppSelector } from '../store/hooks'; import { authService } from '../services/auth'; -import type { GameState, Intent, PlayerState, Socket } from '../types/socket'; +import type { GameState, PlayerState, Socket } from '../types/socket'; const WIDTH = 10; const HEIGHT = 20; const CELL = 28; export function meta({}: Route.MetaArgs) { - return [{ title: 'Red Tetris — Play' }, { name: 'description', content: 'Play Red Tetris - The Ultimate Multiplayer Tetris Experience!' }]; + return [ + { title: 'Red Tetris — Play' }, + { name: 'description', content: 'Play Red Tetris - The Ultimate Multiplayer Tetris Experience!' }, + ]; } function GameComponent() { - const { user } = useAppSelector((state) => state.auth); const [room, setRoom] = useState(''); const [playerId, setPlayerId] = useState(null); const [state, setState] = useState(null); @@ -188,13 +189,6 @@ function GameComponent() { s.emit('start', { roomId: room }); }; - const sendIntent = (intent: Intent) => { - const s = socketRef.current; - if (!s || !playerId) return; - if (state?.status === 'finished' || message) return; - s.emit('intent', { roomId: room, intent }); - }; - const myView = useMemo(() => { if (!state || !playerId) return null; return (state.players?.[playerId] ?? null) as PlayerState | null; @@ -235,7 +229,13 @@ function GameComponent() { return (
); } @@ -445,7 +445,10 @@ function GameComponent() { }; return ( -
+

Room Settings

- +
@@ -478,7 +481,7 @@ function GameComponent() { placeholder='Room code' />
- +
); } - diff --git a/client/vite.config.ts b/client/vite.config.ts index 1762998..8904849 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -7,7 +7,7 @@ import os from 'os'; // 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) { @@ -19,20 +19,19 @@ function getNetworkIP() { } // 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}`; +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()], +export default defineConfig({ + plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], envDir: '..', server: { host: '0.0.0.0', // Allow external connections - port: clientPort + port: clientPort, }, - define: { - 'import.meta.env.VITE_SERVER_URL': JSON.stringify(serverUrl) - } + define: { 'import.meta.env.VITE_SERVER_URL': JSON.stringify(serverUrl) }, }); diff --git a/server/app.ts b/server/app.ts index df9e029..cf947de 100644 --- a/server/app.ts +++ b/server/app.ts @@ -19,17 +19,18 @@ 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); // Helper function to get network IPs -function getNetworkIPs() { - const os = require('os'); +function getNetworkIPs(): string[] { const interfaces = os.networkInterfaces(); - const ips = []; - + const ips: string[] = []; + for (const name of Object.keys(interfaces)) { - for (const networkInterface of interfaces[name]) { + for (const networkInterface of interfaces[name] ?? []) { if (networkInterface.family === 'IPv4' && !networkInterface.internal) { ips.push(networkInterface.address); } @@ -39,7 +40,7 @@ function getNetworkIPs() { } // Dynamic CORS origins - include current network IPs -const clientPort = process.env.CLIENT_PORT || '3001'; +const clientPort = process.env.CLIENT_PORT ?? '3001'; const clientUrlsRaw = process.env.CLIENT_URL ?? `http://localhost:${clientPort}`; const baseOrigins = clientUrlsRaw .split(',') @@ -48,7 +49,7 @@ const baseOrigins = clientUrlsRaw // Add current network IPs dynamically const networkIPs = getNetworkIPs(); -const dynamicOrigins = networkIPs.map(ip => `http://${ip}:${clientPort}`); +const dynamicOrigins = networkIPs.map((ip) => `http://${ip}:${clientPort}`); const allowedOrigins = [...baseOrigins, ...dynamicOrigins]; const io = new IOServer(server, { diff --git a/server/migrations/002_create_games_and_records.sql b/server/migrations/002_create_games_and_records.sql index 5f8d4ac..5a04d13 100644 --- a/server/migrations/002_create_games_and_records.sql +++ b/server/migrations/002_create_games_and_records.sql @@ -3,7 +3,7 @@ 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, + 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) diff --git a/server/tests/db.test.ts b/server/tests/db.test.ts index 3fa993a..cd04cf7 100644 --- a/server/tests/db.test.ts +++ b/server/tests/db.test.ts @@ -12,7 +12,7 @@ describe('db migrations branches', () => { const fakePool = { getConnection: () => Promise.resolve(fakeConn) }; const fakeMysql = { createPool: () => fakePool }; - const db = proxyquire('../../server/db', { 'fs/promises': fakeFs, 'mysql2/promise': fakeMysql }) as DBModule; + const db = proxyquire('../../server/db', { 'fs/promises': fakeFs, 'mysql2/promise': fakeMysql }); await db.runMigrations(); sinon.assert.calledOnce(fakeFs.stat); }); @@ -37,7 +37,7 @@ describe('db migrations branches', () => { const fakePool = { getConnection: () => Promise.resolve(fakeConn) }; const fakeMysql = { createPool: () => fakePool }; - const db = proxyquire('../../server/db', { 'fs/promises': fakeFs, 'mysql2/promise': fakeMysql }) as DBModule; + const db = proxyquire('../../server/db', { 'fs/promises': fakeFs, 'mysql2/promise': fakeMysql }); await db.runMigrations(); sinon.assert.called(fakeConn.query); }); From 9880b4056b7f30fdade8ae3a97004bc9e82ebf25 Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Thu, 26 Feb 2026 15:37:01 +0100 Subject: [PATCH 16/17] Refactor game controls and UI components for improved user experience --- client/app/app.css | 2 +- client/app/components/LoadingOverlay.tsx | 47 +-- client/app/components/auth/LoginForm.tsx | 22 +- client/app/components/auth/RegisterForm.tsx | 32 +- client/app/routes/game.tsx | 354 ++++++++++++-------- client/app/welcome/welcome.tsx | 74 ++-- server/game/GameEngine.ts | 15 +- server/net/socketHandlers.ts | 9 +- 8 files changed, 294 insertions(+), 261 deletions(-) diff --git a/client/app/app.css b/client/app/app.css index 47911e5..d6d62ca 100644 --- a/client/app/app.css +++ b/client/app/app.css @@ -7,7 +7,7 @@ html, body { - background: linear-gradient(to bottom right, #581c87, #1e3a8a, #312e81); + background: #0f172a; min-height: 100vh; @media (prefers-color-scheme: dark) { diff --git a/client/app/components/LoadingOverlay.tsx b/client/app/components/LoadingOverlay.tsx index 59444d5..776469b 100644 --- a/client/app/components/LoadingOverlay.tsx +++ b/client/app/components/LoadingOverlay.tsx @@ -1,49 +1,10 @@ export function LoadingOverlay() { return ( -
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-

Loading...

+
+

Loading...

diff --git a/client/app/components/auth/LoginForm.tsx b/client/app/components/auth/LoginForm.tsx index 3cb630a..b24b9de 100644 --- a/client/app/components/auth/LoginForm.tsx +++ b/client/app/components/auth/LoginForm.tsx @@ -28,21 +28,17 @@ export function LoginForm() { }; return ( -
+
{/* Header */}
-

- - RED TETRIS - -

+

RED TETRIS

Welcome Back!

Sign in to continue your game

{/* Login Form */} -
+
{ @@ -68,7 +64,7 @@ export function LoginForm() { name='username' type='text' required - className='w-full bg-white/5 border border-white/20 rounded-lg px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-400/50 focus:border-cyan-400/50 transition-all duration-200 backdrop-blur-sm' + className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Enter your username' disabled={isLoading} /> @@ -76,7 +72,7 @@ export function LoginForm() {
@@ -85,7 +81,7 @@ export function LoginForm() { name='password' type='password' required - className='w-full bg-white/5 border border-white/20 rounded-lg px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-400/50 focus:border-cyan-400/50 transition-all duration-200 backdrop-blur-sm' + className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Enter your password' disabled={isLoading} /> @@ -95,7 +91,7 @@ export function LoginForm() { @@ -131,7 +127,7 @@ export function LoginForm() { Don't have an account?{' '} Create one here diff --git a/client/app/components/auth/RegisterForm.tsx b/client/app/components/auth/RegisterForm.tsx index b49a44a..837d46b 100644 --- a/client/app/components/auth/RegisterForm.tsx +++ b/client/app/components/auth/RegisterForm.tsx @@ -40,21 +40,17 @@ export function RegisterForm() { }; return ( -
+
{/* Header */}
-

- - RED TETRIS - -

+

RED TETRIS

Join the Game!

Create your account to start playing

{/* Register Form */} -
+
{ @@ -71,7 +67,7 @@ export function RegisterForm() {
@@ -83,7 +79,7 @@ export function RegisterForm() { minLength={3} maxLength={50} pattern='[a-zA-Z0-9_-]+' - className='w-full bg-white/5 border border-white/20 rounded-lg px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-400/50 focus:border-cyan-400/50 transition-all duration-200 backdrop-blur-sm' + className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Choose a unique username (3-50 chars)' disabled={isLoading} /> @@ -91,7 +87,7 @@ export function RegisterForm() {
@@ -100,7 +96,7 @@ export function RegisterForm() { name='email' type='email' required - className='w-full bg-white/5 border border-white/20 rounded-lg px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-400/50 focus:border-cyan-400/50 transition-all duration-200 backdrop-blur-sm' + className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Enter your email address' disabled={isLoading} /> @@ -108,7 +104,7 @@ export function RegisterForm() {
@@ -118,7 +114,7 @@ export function RegisterForm() { type='password' required minLength={6} - className='w-full bg-white/5 border border-white/20 rounded-lg px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-400/50 focus:border-cyan-400/50 transition-all duration-200 backdrop-blur-sm' + className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Create a strong password (6+ chars)' disabled={isLoading} /> @@ -126,7 +122,7 @@ export function RegisterForm() {
@@ -136,7 +132,7 @@ export function RegisterForm() { type='password' required minLength={6} - className='w-full bg-white/5 border border-white/20 rounded-lg px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-400/50 focus:border-cyan-400/50 transition-all duration-200 backdrop-blur-sm' + className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Confirm your password' disabled={isLoading} /> @@ -146,7 +142,7 @@ export function RegisterForm() { @@ -182,7 +178,7 @@ export function RegisterForm() { Already have an account?{' '} Sign in here diff --git a/client/app/routes/game.tsx b/client/app/routes/game.tsx index 1beaece..f170f38 100644 --- a/client/app/routes/game.tsx +++ b/client/app/routes/game.tsx @@ -131,33 +131,71 @@ function GameComponent() { }, []); useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (!playerId || !socketRef.current) return; + const DAS_DELAY = 170; // ms before auto-repeat kicks in + const REPEAT_INTERVAL = 50; // ms between repeated moves + const held = new Map>(); + + const fireIntent = (key: string) => { const s = socketRef.current; - if (e.key === 'ArrowLeft') { - e.preventDefault(); - s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'left' } }); + if (!s) return; + if (key === 'ArrowLeft') s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'left' } }); + else if (key === 'ArrowRight') + s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'right' } }); + else if (key === 'ArrowDown') s.emit('intent', { roomId: room, intent: { type: 'soft' } }); + }; + + const clearKey = (key: string) => { + const timer = held.get(key); + if (timer != null) { + clearTimeout(timer); + held.delete(key); } - if (e.key === 'ArrowRight') { + }; + + const repeatableKeys = new Set(['ArrowLeft', 'ArrowRight', 'ArrowDown']); + + const onKeyDown = (e: KeyboardEvent) => { + if (!playerId || !socketRef.current) return; + const s = socketRef.current; + + if (repeatableKeys.has(e.key)) { e.preventDefault(); - s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'right' } }); + if (held.has(e.key)) return; // already held + fireIntent(e.key); // fire once immediately + // after DAS_DELAY, start rapid repeat + const dasTimer = setTimeout(() => { + const interval = setInterval(() => fireIntent(e.key), REPEAT_INTERVAL); + held.set(e.key, interval as unknown as ReturnType); + }, DAS_DELAY); + held.set(e.key, dasTimer); + return; } if (e.key === 'ArrowUp') { e.preventDefault(); s.emit('intent', { roomId: room, intent: { type: 'rotate' } }); } - if (e.key === 'ArrowDown') { - e.preventDefault(); - s.emit('intent', { roomId: room, intent: { type: 'soft' } }); - } if (e.key === 'Shift') s.emit('intent', { roomId: room, intent: { type: 'hold' } }); if (e.code === 'Space') { e.preventDefault(); s.emit('intent', { roomId: room, intent: { type: 'hard' } }); } }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); + + const onKeyUp = (e: KeyboardEvent) => { + clearKey(e.key); + }; + + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + held.forEach((t) => { + clearTimeout(t); + clearInterval(t); + }); + held.clear(); + }; }, [playerId, room]); const join = () => { @@ -189,6 +227,18 @@ function GameComponent() { s.emit('start', { roomId: room }); }; + const leave = () => { + const s = socketRef.current; + if (!s) return; + s.disconnect(); + setPlayerId(null); + setState(null); + setHostId(null); + setMessage(null); + setError(null); + setRoom(''); + }; + const myView = useMemo(() => { if (!state || !playerId) return null; return (state.players?.[playerId] ?? null) as PlayerState | null; @@ -209,7 +259,7 @@ function GameComponent() { if (prev === true && alive === false) { setMessage('You lost'); } - if ((state.status === 'finished' || state.winnerId) && state.winnerId === playerId) { + if (state.status === 'finished' && state.winnerId === playerId) { setMessage('You win'); } prevAliveRef.current = alive; @@ -221,7 +271,7 @@ function GameComponent() { return (
); } @@ -233,14 +283,17 @@ function GameComponent() { width: CELL, height: CELL, boxSizing: 'border-box', - background: 'rgba(255,255,255,0.06)', - outline: '1px solid rgba(255,255,255,0.03)', + borderRadius: 2, + background: 'rgba(255,255,255,0.12)', + borderTop: '1px solid rgba(255,255,255,0.15)', + borderLeft: '1px solid rgba(255,255,255,0.1)', + borderBottom: '1px solid rgba(0,0,0,0.3)', + borderRight: '1px solid rgba(0,0,0,0.2)', }} /> ); } if (val === -1) { - const penalty = '#4b5563'; return (
); @@ -267,13 +319,12 @@ function GameComponent() { width: CELL, height: CELL, boxSizing: 'border-box', - borderRadius: 3, - background: `linear-gradient(135deg, rgba(255,255,255,0.35) 0%, ${color} 40%, ${color} 60%, rgba(0,0,0,0.35) 100%)`, - borderTop: '2px solid rgba(255,255,255,0.4)', - borderLeft: '2px solid rgba(255,255,255,0.25)', - borderBottom: '2px solid rgba(0,0,0,0.4)', - borderRight: '2px solid rgba(0,0,0,0.25)', - boxShadow: `inset 0 0 6px rgba(255,255,255,0.15), 0 0 4px ${color}55`, + borderRadius: 2, + background: color, + borderTop: '1px solid rgba(255,255,255,0.15)', + borderLeft: '1px solid rgba(255,255,255,0.1)', + borderBottom: '1px solid rgba(0,0,0,0.3)', + borderRight: '1px solid rgba(0,0,0,0.2)', }} /> ); @@ -343,13 +394,13 @@ function GameComponent() { ], }; const PIECE_COLORS: Record = { - I: '#06b6d4', - O: '#f59e0b', - T: '#8b5cf6', - S: '#10b981', - Z: '#ef4444', - J: '#3b82f6', - L: '#f97316', + I: '#0d9488', + O: '#ca8a04', + T: '#9333ea', + S: '#16a34a', + Z: '#dc2626', + J: '#2563eb', + L: '#c2410c', }; const PIECE_ID_COLORS: string[] = [ @@ -376,24 +427,16 @@ function GameComponent() { const isPiece = val > 0; cells.push( - isPiece && isAlive ? ( -
- ) : ( -
- ) +
); } } @@ -409,10 +452,19 @@ function GameComponent() { const mat = type && PIECE_SHAPES[type] ? PIECE_SHAPES[type] : null; const gridSize = 4; const color = (type && PIECE_COLORS[type]) ?? '#60a5fa'; + + // center the piece matrix within the 4x4 grid + const matRows = mat ? mat.length : 0; + const matCols = mat ? mat[0].length : 0; + const rowOffset = Math.floor((gridSize - matRows) / 2); + const colOffset = Math.floor((gridSize - matCols) / 2); + const cells: JSX.Element[] = []; for (let r = 0; r < gridSize; r++) { for (let c = 0; c < gridSize; c++) { - const inMat = Boolean(mat && r < mat.length && c < mat[0].length && mat[r][c]); + const mr = r - rowOffset; + const mc = c - colOffset; + const inMat = Boolean(mat && mr >= 0 && mr < matRows && mc >= 0 && mc < matCols && mat[mr][mc]); cells.push( inMat ? (
) : ( @@ -446,38 +497,35 @@ function GameComponent() { return (
- ← Back + Back
-

- - RED TETRIS - -

-

Play Mode

+

RED TETRIS

+

Play Mode

{/* Room Controls */} -
+

Room Settings

- + setRoom(e.target.value)} - className='w-full px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:border-transparent' + disabled={!!playerId} + className='w-full px-3 py-2 text-sm bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' placeholder='Room code' />
@@ -485,22 +533,24 @@ function GameComponent() {
{state?.status === 'finished' && playerId && hostId && playerId === hostId && ( + )} + {playerId && ( + )}
@@ -518,30 +576,28 @@ function GameComponent() { {/* Status Messages */}
{hostId && playerId === hostId && ( -
- 🔑 Room: +
+ Room: {room}
)} {hostName && ( -
- 👑 Host: +
+ Host: {hostName} {hostId === playerId || state?.hostId === playerId ? ( - (you) + (you) ) : null}
)} {error && ( -
- - {error} +
+ {error}
)} {message && ( -
- - {message} +
+ {message}
)}
@@ -552,39 +608,45 @@ function GameComponent() {
{/* Left Side: Room Controls (Desktop) */}
-
+

Room Settings

- + setRoom(e.target.value)} - className='w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:border-transparent' + disabled={!!playerId} + className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' placeholder='Enter room code' />
- - + {!playerId && ( + + )} + {!playerId && ( + + )} {state?.status === 'finished' && playerId && hostId && playerId === hostId && ( + )} + {playerId && ( + )}
@@ -602,30 +672,28 @@ function GameComponent() { {/* Status Messages */}
{hostId && playerId === hostId && ( -
- 🔑 Room Code: +
+ Room Code: {room}
)} {hostName && ( -
- 👑 Host: +
+ Host: {hostName} {hostId === playerId || state?.hostId === playerId ? ( - (you) + (you) ) : null}
)} {error && ( -
- ❌ Error: - {error} +
+ {error}
)} {message && ( -
- - {message} +
+ {message}
)}
@@ -636,22 +704,22 @@ function GameComponent() { {/* Center: Game Area */}
{/* Hold Piece */} -
+

Hold

- {renderNextPreview(myView?.holdPiece ?? null, 18)} + {renderNextPreview(myView?.holdPiece ?? null, CELL)}
{/* Main Game Board */} -
+

Your Board

-
+
-
+
- {myView?.score ?? 0} -
Score
+ {myView?.score ?? 0} +
Score
{/* Next Piece */} -
+

Next

- {renderNextPreview(myView?.nextPiece ?? null, 18)} + {renderNextPreview(myView?.nextPiece ?? null, CELL)}
@@ -694,13 +762,13 @@ function GameComponent() { {/* Right Side: Opponents */} {opponents.length > 0 && (
-
+

Opponents

{opponents.map((op) => (
{/* Left: Info */}
@@ -710,9 +778,9 @@ function GameComponent() { /> {op.name}
- {op.isHost && 👑 Host} + {op.isHost && Host}
- Score: {op.score} + Score: {op.score}
{/* Right: Board */} -
+
{renderOpponentBoard(op.board ?? null, op.isAlive, 5)}
diff --git a/client/app/welcome/welcome.tsx b/client/app/welcome/welcome.tsx index 142d813..dd35de1 100644 --- a/client/app/welcome/welcome.tsx +++ b/client/app/welcome/welcome.tsx @@ -42,26 +42,22 @@ export function Welcome() { }; return ( -
+
{/* Header */}
-

- - RED TETRIS - -

-

The Ultimate Multiplayer Tetris Experience

+

RED TETRIS

+

Multiplayer Tetris

{user && (
{/* Profile Section */}
-
+
-
- {user.username.charAt(0).toUpperCase()} +
+ {user.username.charAt(0).toUpperCase()}

{user.username}

{user.email}

@@ -72,7 +68,7 @@ export function Welcome() { @@ -83,46 +79,46 @@ export function Welcome() {
- 🎮 PLAY NOW + PLAY NOW

Ready to drop some blocks?

{/* Stats Grid */} -
+

Your Stats

{statsLoading ? (
-
-

Loading stats...

+
+

Loading stats...

) : stats && stats.gamesPlayed > 0 ? (
-
-
{stats.gamesPlayed}
-
Games Played
+
+
{stats.gamesPlayed}
+
Games Played
-
-
{stats.highScore.toLocaleString()}
-
High Score
+
+
{stats.highScore.toLocaleString()}
+
High Score
-
+
{stats.wins}
-
Wins
+
Wins
-
-
{stats.averageScore.toLocaleString()}
-
Avg Score
+
+
{stats.averageScore.toLocaleString()}
+
Avg Score
-
-
{stats.winRate}%
-
Win Rate
+
+
{stats.winRate}%
+
Win Rate
-
-
#{stats.bestPlace}
-
Best Place
+
+
#{stats.bestPlace}
+
Best Place
) : ( @@ -135,7 +131,7 @@ export function Welcome() { {/* Recent Games */} {recentGames.length > 0 && ( -
+

Recent Games

{recentGames.map((game) => { @@ -143,14 +139,14 @@ export function Welcome() { return (
#{myRecord?.place ?? '?'} @@ -169,8 +165,8 @@ export function Welcome() {
-
{myRecord?.score.toLocaleString() ?? 0}
-
points
+
{myRecord?.score.toLocaleString() ?? 0}
+
points
); diff --git a/server/game/GameEngine.ts b/server/game/GameEngine.ts index b21a8fb..c56c15d 100644 --- a/server/game/GameEngine.ts +++ b/server/game/GameEngine.ts @@ -277,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; diff --git a/server/net/socketHandlers.ts b/server/net/socketHandlers.ts index d53ec0d..6157b3a 100644 --- a/server/net/socketHandlers.ts +++ b/server/net/socketHandlers.ts @@ -23,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 = {}; @@ -195,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}`); From 9a55ff3b80ce0120bad71c6ffd7c65307af591e1 Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Thu, 26 Feb 2026 15:50:50 +0100 Subject: [PATCH 17/17] feat: enhance authentication flow with hydration and loading states in ProtectedRoute and PublicOnlyRoute --- client/app/components/auth/ProtectedRoute.tsx | 18 ++++++++++---- .../app/components/auth/PublicOnlyRoute.tsx | 18 ++++++++++---- client/app/root.tsx | 24 ++++++++++++------- client/app/routes/game.tsx | 8 +++---- client/app/store/authSlice.ts | 14 ++++++++--- 5 files changed, 59 insertions(+), 23 deletions(-) diff --git a/client/app/components/auth/ProtectedRoute.tsx b/client/app/components/auth/ProtectedRoute.tsx index 36c01df..16e0840 100644 --- a/client/app/components/auth/ProtectedRoute.tsx +++ b/client/app/components/auth/ProtectedRoute.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { Navigate } from 'react-router'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { fetchProfile } from '../../store/authSlice'; +import { fetchProfile, hydrateAuth } from '../../store/authSlice'; import { LoadingOverlay } from '../LoadingOverlay'; interface ProtectedRouteProps { @@ -10,13 +10,23 @@ interface ProtectedRouteProps { export function ProtectedRoute({ children }: ProtectedRouteProps) { const dispatch = useAppDispatch(); - const { isAuthenticated, isLoading, user } = useAppSelector((state) => state.auth); + const { isAuthenticated, isLoading, user, hydrated } = useAppSelector((state) => state.auth); useEffect(() => { - if (isAuthenticated && !user) { + if (!hydrated) { + dispatch(hydrateAuth()); + } + }, [dispatch, hydrated]); + + useEffect(() => { + if (hydrated && isAuthenticated && !user) { void dispatch(fetchProfile()); } - }, [dispatch, isAuthenticated, user]); + }, [dispatch, hydrated, isAuthenticated, user]); + + if (!hydrated) { + return ; + } if (!isAuthenticated) { return ( diff --git a/client/app/components/auth/PublicOnlyRoute.tsx b/client/app/components/auth/PublicOnlyRoute.tsx index 410fdca..434e0f7 100644 --- a/client/app/components/auth/PublicOnlyRoute.tsx +++ b/client/app/components/auth/PublicOnlyRoute.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { Navigate } from 'react-router'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { fetchProfile } from '../../store/authSlice'; +import { fetchProfile, hydrateAuth } from '../../store/authSlice'; import { LoadingOverlay } from '../LoadingOverlay'; interface PublicOnlyRouteProps { @@ -10,13 +10,23 @@ interface PublicOnlyRouteProps { export function PublicOnlyRoute({ children }: PublicOnlyRouteProps) { const dispatch = useAppDispatch(); - const { isAuthenticated, isLoading, user } = useAppSelector((state) => state.auth); + const { isAuthenticated, isLoading, user, hydrated } = useAppSelector((state) => state.auth); useEffect(() => { - if (isAuthenticated && !user) { + if (!hydrated) { + dispatch(hydrateAuth()); + } + }, [dispatch, hydrated]); + + useEffect(() => { + if (hydrated && isAuthenticated && !user) { void dispatch(fetchProfile()); } - }, [dispatch, isAuthenticated, user]); + }, [dispatch, hydrated, isAuthenticated, user]); + + if (!hydrated) { + return ; + } if (isAuthenticated) { return ( diff --git a/client/app/root.tsx b/client/app/root.tsx index 5b1e672..4eadcc5 100644 --- a/client/app/root.tsx +++ b/client/app/root.tsx @@ -62,14 +62,22 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { } return ( -
-

{message}

-

{details}

- {stack && ( -
-          {stack}
-        
- )} +
+
+

{message}

+

{details}

+ {stack && ( +
+            {stack}
+          
+ )} + + Back to Home + +
); } diff --git a/client/app/routes/game.tsx b/client/app/routes/game.tsx index f170f38..c4b01fe 100644 --- a/client/app/routes/game.tsx +++ b/client/app/routes/game.tsx @@ -50,7 +50,9 @@ function GameComponent() { const s = io(url, { autoConnect: false, auth: { token } }) as unknown as Socket; socketRef.current = s; - s.on('connect', () => console.log('connected to server')); + s.on('connect', () => { + // connection established + }); // server may emit either a raw state object or { state, hostId } historically. s.on('state', (payload: GameState | { state: GameState; hostId?: string }) => { @@ -98,7 +100,6 @@ function GameComponent() { setError(info?.message ?? info?.reason ?? 'RESTART_FAILED'); }); s.on('game_over', (info: { message?: string }) => { - console.log('client: received game_over', info); setMessage(info?.message ?? 'game over'); }); s.on('winner', (info: { message?: string }) => { @@ -139,8 +140,7 @@ function GameComponent() { const s = socketRef.current; if (!s) return; if (key === 'ArrowLeft') s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'left' } }); - else if (key === 'ArrowRight') - s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'right' } }); + else if (key === 'ArrowRight') s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'right' } }); else if (key === 'ArrowDown') s.emit('intent', { roomId: room, intent: { type: 'soft' } }); }; diff --git a/client/app/store/authSlice.ts b/client/app/store/authSlice.ts index d719aea..de0949f 100644 --- a/client/app/store/authSlice.ts +++ b/client/app/store/authSlice.ts @@ -7,14 +7,16 @@ export interface AuthState { isAuthenticated: boolean; isLoading: boolean; error: string | null; + hydrated: boolean; } const initialState: AuthState = { user: null, - token: authService.getToken(), - isAuthenticated: !!authService.getToken(), + token: null, + isAuthenticated: false, isLoading: false, error: null, + hydrated: false, }; // Async thunks @@ -57,6 +59,12 @@ const authSlice = createSlice({ name: 'auth', initialState, reducers: { + hydrateAuth: (state) => { + const token = authService.getToken(); + state.token = token; + state.isAuthenticated = !!token; + state.hydrated = true; + }, clearError: (state) => { state.error = null; }, @@ -142,5 +150,5 @@ const authSlice = createSlice({ }, }); -export const { clearError, setCredentials } = authSlice.actions; +export const { hydrateAuth, clearError, setCredentials } = authSlice.actions; export default authSlice.reducer;