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..5ea5f59 100644 --- a/.env.example +++ b/.env.example @@ -4,14 +4,18 @@ DB_PASSWORD=app_pw_change_me DB_NAME=red_tetris DB_PORT=3306 -CLIENT_URL=http://localhost:3001,http://127.0.0.1:3001 +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=3001 # 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/.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..d6d62ca 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: #0f172a; + 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..776469b --- /dev/null +++ b/client/app/components/LoadingOverlay.tsx @@ -0,0 +1,12 @@ +export function LoadingOverlay() { + return ( +
+
+
+
+

Loading...

+
+
+
+ ); +} diff --git a/client/app/components/auth/LoginForm.tsx b/client/app/components/auth/LoginForm.tsx index 8a88915..b24b9de 100644 --- a/client/app/components/auth/LoginForm.tsx +++ b/client/app/components/auth/LoginForm.tsx @@ -28,85 +28,114 @@ 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..16e0840 100644 --- a/client/app/components/auth/ProtectedRoute.tsx +++ b/client/app/components/auth/ProtectedRoute.tsx @@ -1,7 +1,8 @@ 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 { children: React.ReactNode; @@ -9,20 +10,22 @@ 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 (isLoading || (isAuthenticated && !user)) { - return ( -
-
Loading...
-
- ); + if (!hydrated) { + return ; } if (!isAuthenticated) { @@ -34,5 +37,10 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) { ); } - 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..434e0f7 100644 --- a/client/app/components/auth/PublicOnlyRoute.tsx +++ b/client/app/components/auth/PublicOnlyRoute.tsx @@ -1,7 +1,8 @@ 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 { children: React.ReactNode; @@ -9,20 +10,22 @@ 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 (isLoading || (isAuthenticated && !user)) { - return ( -
-
Loading...
-
- ); + if (!hydrated) { + return ; } if (isAuthenticated) { @@ -34,5 +37,10 @@ export function PublicOnlyRoute({ children }: PublicOnlyRouteProps) { ); } - 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..837d46b 100644 --- a/client/app/components/auth/RegisterForm.tsx +++ b/client/app/components/auth/RegisterForm.tsx @@ -40,124 +40,153 @@ 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..4eadcc5 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 && } ); } @@ -57,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 94dda7c..c4b01fe 100644 --- a/client/app/routes/game.tsx +++ b/client/app/routes/game.tsx @@ -1,24 +1,31 @@ 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 { 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 = 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 [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[] => { @@ -43,10 +50,13 @@ export default function GameRoute() { 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 }) => { + setActionLoading(false); // normalize payload to `state` object const incoming: GameState = 'state' in payload ? payload.state : payload; setState(incoming); @@ -56,6 +66,7 @@ export default function GameRoute() { }); s.on('joined', async (info: { roomId?: string }) => { + setActionLoading(false); setRoom(info.roomId ?? room); try { const profile = await authService.getProfile(); @@ -66,6 +77,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(); @@ -80,13 +92,14 @@ 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 }) => { - console.log('client: received game_over', info); setMessage(info?.message ?? 'game over'); }); s.on('winner', (info: { message?: string }) => { @@ -94,15 +107,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)); }); @@ -115,27 +132,77 @@ export default function GameRoute() { }, []); useEffect(() => { - const onKey = (e: KeyboardEvent) => { + 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 (!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); + } + }; + + const repeatableKeys = new Set(['ArrowLeft', 'ArrowRight', 'ArrowDown']); + + const onKeyDown = (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 (repeatableKeys.has(e.key)) { + e.preventDefault(); + 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 === '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 = () => { const s = socketRef.current; if (!s) return; if (!s.connected) s.connect(); + setActionLoading(true); s.emit('join', { roomId: room }); }; @@ -143,6 +210,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); @@ -155,14 +223,20 @@ export default function GameRoute() { setError('ONLY_HOST_CAN_START'); return; } + setActionLoading(true); s.emit('start', { roomId: room }); }; - const sendIntent = (intent: Intent) => { + const leave = () => { const s = socketRef.current; - if (!s || !playerId) return; - if (state?.status === 'finished' || message) return; - s.emit('intent', { roomId: room, intent }); + if (!s) return; + s.disconnect(); + setPlayerId(null); + setState(null); + setHostId(null); + setMessage(null); + setError(null); + setRoom(''); }; const myView = useMemo(() => { @@ -185,7 +259,7 @@ export default function GameRoute() { 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; @@ -193,29 +267,65 @@ 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) { + return ( +
+ ); + } + const color = PIECE_ID_COLORS[val] ?? '#999'; return (
); }; @@ -284,13 +394,13 @@ export default function GameRoute() { ], }; 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[] = [ @@ -315,10 +425,17 @@ 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(
); } @@ -335,16 +452,41 @@ export default function GameRoute() { 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 bg = inMat ? color : 'transparent'; + 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 ? ( +
+ ) : ( +
+ ) ); } } @@ -355,143 +497,322 @@ 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} -
- )} - {hostName && ( -
- Host: {hostName} {hostId === playerId || state?.hostId === playerId ? '(you)' : ''} + Back + +
+

RED TETRIS

+

Play Mode

- )} - {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)} + 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' + />
-
-
- Score: {myView?.score ?? 0} -
-
-
- - + +
+ {state?.status === 'finished' && playerId && hostId && playerId === hostId && ( + + )} + {playerId && ( + + )}
-
-
- 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) => ( + {/* Main Game Layout */} +
+ {/* Left Side: Room Controls (Desktop) */} +
+
+

Room Settings

+ +
+
+ + setRoom(e.target.value)} + 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 && ( + + )} +
+ + {/* Status Messages */} +
+ {hostId && playerId === hostId && ( +
+ Room Code: + {room} +
+ )} + {hostName && ( +
+ Host: + {hostName} + {hostId === playerId || state?.hostId === playerId ? ( + (you) + ) : null} +
+ )} + {error && ( +
+ {error} +
+ )} + {message && ( +
+ {message} +
+ )} +
+
+
+
+ + {/* Center: Game Area */} +
+ {/* Hold Piece */} +
+

Hold

-
-
-
{op.name}
-
Score: {op.score}
+ {renderNextPreview(myView?.holdPiece ?? null, CELL)} +
+
+ + {/* 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, CELL)} +
+
+ + {/* 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..61899b5 100644 --- a/client/app/routes/home.tsx +++ b/client/app/routes/home.tsx @@ -3,7 +3,10 @@ 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/services/auth.ts b/client/app/services/auth.ts index 960a2a9..3bbbc18 100644 --- a/client/app/services/auth.ts +++ b/client/app/services/auth.ts @@ -1,4 +1,4 @@ -const API_URL = (import.meta.env.VITE_SERVER_URL as string) || 'http://localhost:3002'; +const API_URL = (import.meta.env.VITE_SERVER_URL as string) ?? 'http://localhost:3002'; export interface User { id: number; diff --git a/client/app/services/games.ts b/client/app/services/games.ts new file mode 100644 index 0000000..3917940 --- /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()) as { data: PaginatedGames; error?: { message: string } }; + if (!response.ok) { + throw new Error(result.error?.message ?? 'Failed to fetch games'); + } + + return result.data; + }; + + 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/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; diff --git a/client/app/welcome/welcome.tsx b/client/app/welcome/welcome.tsx index 7e80c67..dd35de1 100644 --- a/client/app/welcome/welcome.tsx +++ b/client/app/welcome/welcome.tsx @@ -1,117 +1,182 @@ +import { useEffect, useState } from 'react'; 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'; +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()); }; return ( -
-
-
-
- React Router - React Router -
+
+
+ {/* Header */} +
+

RED TETRIS

+

Multiplayer Tetris

-
- {user && ( -
-

- Welcome, {user.username}! -

-
-

- Email: {user.email} -

-

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

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

{user.username}

+

{user.email}

+

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

+
+ +
- + PLAY NOW + +

Ready to drop some blocks?

+
+ + {/* Stats Grid */} +
+

Your Stats

+ {statsLoading ? ( +
+
+

Loading stats...

+
+ ) : 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
+
+
+ ) : ( +
+

No games played yet

+

Play your first game to see your stats!

+
+ )}
- )} - -
+ + {/* Recent Games */} + {recentGames.length > 0 && ( +
+

Recent Games

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