diff --git a/.DS_Store b/.DS_Store index 105922f..81419e3 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index ffbe5f8..0000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,142 +0,0 @@ -# Ranked EQAO — Claude Code Guide - -## Project Overview -A ranked quiz app based on EQAO (Ontario standardized tests). -Users sign in with Google, answer questions by grade and subject, -and compete on leaderboards. - -## Tech Stack -- **Runtime:** Node.js + Express -- **Database:** PostgreSQL via Prisma ORM -- **Auth:** Google OAuth 2.0 (Passport.js) + JWT -- **AI:** Gemini API (passage + question generation for English; answer checking) -- **Frontend:** React (Vite) — `frontend/` directory - -## Grades -Only three grades are supported: **3, 6, 9** -- In the schema these are the `Grade` enum: `G3`, `G6`, `G9` -- Never use raw integers for grade — always use the enum - -## Subjects -Two sections per quiz: `MATH` and `ENGLISH` (reading + writing combined) -- The Prisma `Subject` enum currently has `MATH`, `READING`, `WRITING` — these remain as-is for DB storage -- In the UI and game logic, treat READING + WRITING as a single **English** section - -## Project Structure -``` -/ -├── prisma/ -│ └── schema.prisma # All DB models -├── src/ -│ ├── db.js # Prisma singleton -│ ├── auth/ -│ │ └── google.js # Google OAuth + JWT issuing -│ ├── middleware/ -│ │ └── auth.js # JWT verification middleware -│ └── services/ -│ └── leaderboard.js # Leaderboard upsert + query logic -├── frontend/ -│ └── src/ -│ ├── pages/ -│ │ ├── Login.jsx # Pre-login landing page -│ │ ├── Dashboard.jsx # Post-login dashboard (to be built) -│ │ └── MathGame.jsx # SCRAPPED — delete this file -│ ├── App.jsx -│ ├── index.css -│ └── main.jsx -├── .env # Never commit this -├── CLAUDE.md -└── package.json -``` - -## Environment Variables -| Variable | Purpose | -|---|---| -| `DATABASE_URL` | PostgreSQL connection string | -| `GOOGLE_CLIENT_ID` | Google OAuth app client ID | -| `GOOGLE_CLIENT_SECRET` | Google OAuth app client secret | -| `GOOGLE_CALLBACK_URL` | OAuth redirect URI | -| `JWT_SECRET` | Secret for signing JWTs | -| `GEMINI_API_KEY` | Gemini API key for English question/passage generation and answer checking | -| `PORT` | Express server port (default 3000) | -| `FRONTEND_URL` | Frontend origin for CORS | - -## Key Conventions -- Always use `Grade` enum (`G3`, `G6`, `G9`) — never raw numbers -- Always use `Subject` enum (`MATH`, `READING`, `WRITING`) -- All routes that touch user data require the `auth.js` JWT middleware -- Leaderboard is scoped per `[userId, grade, subject]` — unique constraint enforced in DB -- Sessions track full answer history via `UserAnswer` for future analytics - -## Database Commands -```bash -npx prisma migrate dev --name # new migration -npx prisma generate # regenerate client after schema change -npx prisma studio # visual DB browser -``` - ---- - -## Frontend Pages - -### Login.jsx (Pre-login Landing Page) — KEEP, DO NOT ADD DEBUG BUTTON -- The current pre-login landing page and Google sign-in button are good — preserve this design -- The page will eventually be a longer scrollable page with a top navigation bar linking to info sections and other useful tabs (TBD before release) -- **DO NOT add any debug/bypass button that skips Google login** — all such buttons must be removed if found anywhere in the codebase -- For now, leave the landing page as-is; extended layout/nav is a pre-release task - -### MathGame.jsx — SCRAPPED, DELETE THIS FILE -- `frontend/src/pages/MathGame.jsx` is fully scrapped — delete it -- Remove all routes, imports, and links pointing to MathGame anywhere in the codebase -- The actual EQAO quiz experience will be built fresh (see Quiz UI section below) - -### Dashboard.jsx (Post-login) — TO BE BUILT -- Replaces the current "Coming Soon" placeholder -- Should be a rich, well-designed dashboard with multiple sections/widgets, for example: - - User greeting with avatar - - Stats summary (games played, best scores, rank per grade) - - Leaderboard preview - - Recent activity - - A **"Start Game"** button (prominent CTA) — the button exists in the UI but does NOT wire up to the quiz yet; the quiz flow is not being programmed at this stage -- The exact set of dashboard widgets/tabs is TBD — design it to look complete and polished even if most data is placeholder - ---- - -## Quiz UI (To Be Built — Design Reference) - -### General Flow -The quiz covers **two sections**: Math and English. The user completes them in order. - -After finishing a section, the app checks answers and shows one of two outcomes: -- **100% correct** → proceed to the next section (or end the quiz if both are done) -- **At least 1 wrong** → must redo the section; keep retrying until 100% correct before advancing - -There is no partial credit / moving on with mistakes — the player must get a perfect section to continue. - -### Math Section -- Questions are **pre-generated** (not from Gemini) but with **randomised values each time** so the same question template produces different numbers on each attempt -- Questions should be creative and contextual — avoid trivial `a + b` style arithmetic; think word problems, patterns, multi-step reasoning appropriate to the grade level -- Multiple choice format (matching the original EQAO UI style — see design reference below) - -### English Section -- **Gemini API** generates a reading passage appropriate for the grade -- Gemini also generates comprehension questions based on that passage -- Gemini checks free-response and paragraph writing answers (not just multiple choice) -- The section combines reading comprehension and a short writing component - -### Quiz UI Design -- The actual in-quiz UI (question display, answer choices, progress) should stay **visually identical or very close to the original MathGame design** — same layout, card style, fonts, colour scheme -- Reference the existing `index.css` (`.math-game`, `.choice-btn`, `.grade-btn`, etc.) and replicate that look for the new quiz component -- Upload of reference screenshots is pending — when provided, match them closely - ---- - -## What's Not Built Yet -- [ ] API routes (auth, sessions, leaderboard, questions) -- [ ] Gemini service for English passage + question generation and answer checking -- [BUILT] Math question templates with randomised values -- [BUILT] Quiz game component (replaces scrapped MathGame.jsx) -- [HALF-BUILT] Post-login Dashboard (rich version, not "Coming Soon" placeholder) -- [HALF-BUILT] Pre-login landing page extended layout + top nav (pre-release) -- [ ] Question seeding/storage strategy -- [ ] Frontend wiring for "Start Game" button diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2a660b6..cbea245 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,8 @@ import Game from './pages/Game'; import Queue from './pages/Queue'; import RankedGame from './pages/RankedGame'; import Settings from './pages/Settings'; +import About from './pages/About'; +import FAQ from './pages/FAQ'; function getValidStoredToken() { const token = sessionStorage.getItem('ranked_token'); @@ -41,6 +43,8 @@ export default function App() { : } /> : } /> : } /> + } /> + } /> diff --git a/frontend/src/index.css b/frontend/src/index.css index d0aecca..0593e3e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -650,6 +650,19 @@ a { color: inherit; text-decoration: none; } background: var(--gold); color: #07080f; box-shadow: 0 0 28px rgba(245,197,24,0.3), 0 4px 14px rgba(0,0,0,0.4); + position: relative; + overflow: hidden; +} + +.db-btn--gold::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(105deg, transparent 30%, rgba(255,255,255,0.35) 50%, transparent 70%); + background-size: 300% 100%; + background-position: -200% center; + animation: shimmerSweep 3s ease-in-out infinite; + pointer-events: none; } .db-btn--gold:hover { @@ -995,11 +1008,15 @@ a { color: inherit; text-decoration: none; } gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border); - transition: background 0.15s; + transition: background 0.15s, transform 0.15s, box-shadow 0.15s; } .match-row:last-child { border-bottom: none; } -.match-row:hover { background: rgba(255,255,255,0.02); } +.match-row:hover { + background: rgba(255,255,255,0.02); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.2); +} .match-row--win { border-left: 2px solid #22c55e; } .match-row--loss { border-left: 2px solid #ef4444; } @@ -2177,4 +2194,29 @@ a { color: inherit; text-decoration: none; } .dash-grade-tab { padding: 8px 14px; font-size: 12px; } .db-hero__name { font-size: clamp(38px, 13vw, 72px); } .db-grades { grid-template-columns: 1fr; } +} + +/* Quiz nav button press feel */ +.quiz-nav-btn { + transition: transform 0.1s ease !important; +} +.quiz-nav-btn:active { + transform: scale(0.96) !important; +} + +@keyframes shimmerSweep { + 0% { background-position: -200% center; } + 100% { background-position: 200% center; } +} + +@keyframes rowFadeUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes gradeTabBounce { + 0% { transform: scale(1); } + 40% { transform: scale(0.92); } + 70% { transform: scale(1.04); } + 100% { transform: scale(1); } } \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index a5efd66..7f716e8 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -149,10 +149,11 @@ function LeaderboardTable({ entries, loading, highlightRank, tab }) { Player {tab === 'elo' ? 'ELO' : 'Best Time'} - {padded.map(entry => { + {padded.map((entry, idx) => { + const rowAnim = { animation: 'rowFadeUp 0.3s ease-out both', animationDelay: `${idx * 40}ms` }; if (entry.ghost) { return ( -
+
{entry.rank}
@@ -167,7 +168,7 @@ function LeaderboardTable({ entries, loading, highlightRank, tab }) { const playerLabel = entry.username ? `@${entry.username}` : entry.displayName; const fallbackLetter = (entry.username ?? entry.displayName)?.[0]?.toUpperCase() ?? '?'; return ( -
+
3 ? ' lb-rank--num' : ''}`} style={rankStyle ?? undefined}> {entry.rank} @@ -193,40 +194,69 @@ function MatchHistory({ matches, loading }) { if (loading) { return (
- {[0,1,2].map(i =>
)} + {[0, 1, 2].map(i => ( +
+ ))}
); } if (!matches.length) { return (
-
⚔️
- No ranked matches yet +
🕐
+ No games played yet Queue up to start climbing
); } return (
- {matches.map((m, i) => { + {matches.slice(0, 5).map((m, i) => { const sign = m.eloChange >= 0 ? '+' : ''; + const eloDeltaColor = m.eloChange >= 0 ? '#1b8a2d' : '#c62828'; return (
-
- {m.won ? 'W' : 'L'} + {/* Win/loss emoji icon */} +
+ {m.won ? '🏆' : '💀'}
-
- {m.eloAfter.toLocaleString()} ELO - {m.timeSpentMs != null && ( - {fmtTime(m.timeSpentMs)} - )} -
-
- = 0 ? ' match-elo-delta--pos' : ' match-elo-delta--neg'}`}> - {sign}{m.eloChange} - - {fmtRelative(m.playedAt)} + + {/* Center: grade badge · label · ELO delta · time · ago */} +
+
+ + {m.grade} + + + {m.won ? 'Victory' : 'Defeat'} + + + {sign}{m.eloChange} ELO + +
+
+ {fmtRelative(m.playedAt)} +
+ + {/* Right: ELO after */} + + {m.eloAfter?.toLocaleString()} ELO +
); })} @@ -273,6 +303,8 @@ export default function Dashboard() { const [matchLoading, setMatchLoading] = useState(false); const [error, setError] = useState(null); const [statsReady, setStatsReady] = useState(false); + const [bouncingGrade, setBouncingGrade] = useState(null); + const [onlineCount, setOnlineCount] = useState(null); useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -327,6 +359,18 @@ export default function Dashboard() { .finally(() => setMatchLoading(false)); }, [token, selectedGrade]); + useEffect(() => { + const fetchOnline = () => { + fetch(`${API}/online-count`) + .then(r => r.json()) + .then(d => setOnlineCount(d.online ?? 0)) + .catch(() => {}); + }; + fetchOnline(); + const id = setInterval(fetchOnline, 30000); + return () => clearInterval(id); + }, []); + const signOut = () => { sessionStorage.removeItem('ranked_token'); navigate('/'); }; const currentEntry = stats.find(e => e.grade === selectedGrade); @@ -402,7 +446,12 @@ export default function Dashboard() { @@ -420,6 +469,25 @@ export default function Dashboard() {
+ {/* ── Online count ── */} + {onlineCount !== null && ( +
+ + + {onlineCount} {onlineCount === 1 ? 'player' : 'players'} online + +
+ )} + {/* ── ELO + Tier progress ── */}
diff --git a/frontend/src/pages/Game.jsx b/frontend/src/pages/Game.jsx index cd155e2..0cd40fa 100644 --- a/frontend/src/pages/Game.jsx +++ b/frontend/src/pages/Game.jsx @@ -10,16 +10,48 @@ function fmtDuration(ms) { return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; } +function GlobalStyles() { + return ( + + ); +} + // ── ProgressStrip ────────────────────────────────────────────── function ProgressStrip({ total, current, answers, onJump }) { + const [fillCounts, setFillCounts] = useState({}); + const prevAnswersRef = useRef({}); + + useEffect(() => { + const updates = {}; + for (let qi = 0; qi < total; qi++) { + if (answers[qi] != null && prevAnswersRef.current[qi] == null) updates[qi] = true; + } + if (Object.keys(updates).length > 0) { + setFillCounts(prev => { + const next = { ...prev }; + for (const qi of Object.keys(updates)) next[Number(qi)] = (prev[Number(qi)] || 0) + 1; + return next; + }); + } + prevAnswersRef.current = answers; + }, [answers, total]); + const items = []; for (let qi = 0; qi < total; qi++) { - const active = qi === current; - const answered = answers[qi] != null; - const filled = answered; + const active = qi === current; + const filled = answers[qi] != null; + const popCount = fillCounts[qi] || 0; items.push(
- {qIdx > 0 && } + {qIdx > 0 && } {qIdx === STAGE_SIZE - 1 ? ( - ) : ( - + )}
@@ -267,22 +323,65 @@ function StageResult({ stage, wrongCount, questions, answers: initialAnswers, on function CompleteScreen({ duration, isNewBest, onDashboard }) { return ( -
-
-
🎉
-

Quiz Complete!

-

You completed both stages of the Mathematics quiz.

-
- ⏱ {fmtDuration(duration)} +
+
+
+ Mathematics +
+
+ COMPLETE +
+
+
+ {fmtDuration(duration)}
{isNewBest && ( -
- 🏆 New Personal Best! +
+ New Personal Best!
)} - +
+ +
); @@ -295,6 +394,11 @@ export default function Game() { const { state } = useLocation(); const grade = state?.grade ?? null; + if (!state) { + navigate('/dashboard', { replace: true }); + return null; + } + const [phase, setPhase] = useState('loading'); const [stage, setStage] = useState(1); const [questions, setQuestions] = useState([]); @@ -306,6 +410,7 @@ export default function Game() { const [elapsed, setElapsed] = useState(0); const [fetchError, setFetchError] = useState(false); const [isNewBest, setIsNewBest] = useState(false); + const [stageFlash, setStageFlash] = useState(false); const timerStartRef = useRef(null); const intervalRef = useRef(null); @@ -422,12 +527,17 @@ export default function Game() { async function handleResultNext() { if (stage === 1) { s1CorrectRef.current = STAGE_SIZE - wrongCount; - loadStage(2); + setStageFlash(true); + setTimeout(() => { + setStageFlash(false); + loadStage(2); + }, 800); } else { endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount)); const token = sessionStorage.getItem('ranked_token'); const timeMs = timerStartRef.current ? Date.now() - timerStartRef.current : 0; stopTimer(); + console.log('Submitting best time:', grade, timeMs); let isNewBest = false; try { const res = await fetch(`${API}/me/best-time`, { @@ -454,11 +564,10 @@ export default function Game() { if (phase === 'loading') return (
-
); if (phase === 'intro') return { if (stage === 1) { startTimer(); startSession(); } setPhase('question'); }} />; - if (phase === 'question') return ; + if (phase === 'question') return ; if (phase === 'result') return ; if (phase === 'complete') return navigate('/dashboard')} />; return null; @@ -466,6 +575,7 @@ export default function Game() { return ( <> + {timerRunning && (
)} {renderPhase()} + {stageFlash && ( +
+
+ Stage 1 Complete. +
+
+ )} ); } diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 4f7e252..acab083 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,139 +1,777 @@ -import { useState } from 'react'; -import LeaderboardModal from '../components/LeaderboardModal'; +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; const API = import.meta.env.VITE_API_URL; -/* Animated background shapes — generated once, stable positions */ const DIAMONDS = [ - { left: '8%', top: '15%', size: 14, dur: '9s', delay: '0s' }, - { left: '22%', top: '60%', size: 10, dur: '13s', delay: '2s' }, - { left: '55%', top: '25%', size: 18, dur: '11s', delay: '0.5s' }, - { left: '72%', top: '70%', size: 12, dur: '8s', delay: '3s' }, - { left: '88%', top: '35%', size: 16, dur: '14s', delay: '1s' }, - { left: '38%', top: '80%', size: 8, dur: '10s', delay: '4s' }, - { left: '64%', top: '12%', size: 11, dur: '12s', delay: '1.5s' }, + { left: '5%', top: '12%', size: 10, dur: '9s', delay: '0s' }, + { left: '18%', top: '58%', size: 8, dur: '13s', delay: '2s' }, + { left: '30%', top: '22%', size: 14, dur: '11s', delay: '0.5s' }, + { left: '48%', top: '72%', size: 7, dur: '10s', delay: '1.5s' }, + { left: '58%', top: '20%', size: 16, dur: '12s', delay: '0.8s' }, + { left: '70%', top: '65%', size: 10, dur: '8s', delay: '3s' }, + { left: '82%', top: '30%', size: 13, dur: '14s', delay: '1s' }, + { left: '90%', top: '75%', size: 9, dur: '9s', delay: '4s' }, + { left: '38%', top: '45%', size: 6, dur: '15s', delay: '2.5s' }, + { left: '62%', top: '88%', size: 11, dur: '11s', delay: '0.3s' }, + { left: '12%', top: '82%', size: 8, dur: '13s', delay: '1.8s' }, + { left: '76%', top: '10%', size: 12, dur: '10s', delay: '3.5s' }, ]; const RINGS = [ - { left: '15%', top: '40%', size: 60, dur: '7s', delay: '0s' }, - { left: '80%', top: '20%', size: 44, dur: '9s', delay: '2.5s' }, - { left: '50%', top: '65%', size: 80, dur: '11s', delay: '1s' }, + { left: '10%', top: '35%', size: 60, dur: '7s', delay: '0s' }, + { left: '78%', top: '18%', size: 44, dur: '9s', delay: '2.5s' }, + { left: '45%', top: '62%', size: 80, dur: '11s', delay: '1s' }, + { left: '88%', top: '55%', size: 36, dur: '8s', delay: '3.5s' }, ]; const SCANLINES = [ - { top: '30%', width: '160px', left: '5%', dur: '8s', delay: '0s' }, - { top: '55%', width: '120px', left: '75%', dur: '10s', delay: '3s' }, - { top: '20%', width: '200px', left: '40%', dur: '12s', delay: '1.5s' }, + { top: '28%', width: '160px', left: '5%', dur: '8s', delay: '0s' }, + { top: '52%', width: '120px', left: '74%', dur: '10s', delay: '3s' }, + { top: '18%', width: '200px', left: '38%', dur: '12s', delay: '1.5s' }, + { top: '75%', width: '140px', left: '20%', dur: '9s', delay: '2s' }, ]; +const INPUT_STYLE = { + width: '100%', padding: '12px 16px', background: 'rgba(255,255,255,0.07)', + border: '1px solid rgba(255,255,255,0.18)', borderRadius: 10, color: '#fff', + fontSize: 15, fontFamily: 'system-ui,sans-serif', outline: 'none', boxSizing: 'border-box', +}; + +const ERROR_MSGS = { + email_taken: 'An account with this email already exists.', + username_taken: 'That username is already taken.', + use_google: 'This account uses Google sign-in — use the Google button above.', + invalid_credentials: 'Incorrect email or password.', + invalid_email: 'Please enter a valid email address.', + password_too_short: 'Password must be at least 8 characters.', + invalid_username: 'Username must be 3–20 characters: letters, numbers, underscores only.', + too_many_attempts: 'Too many attempts — please wait a few minutes and try again.', +}; + +const RANK_COLORS = ['#f5c518', '#c0ccda', '#e0915a']; + +function fmtTime(ms) { + if (ms == null) return '—'; + const s = Math.floor(ms / 1000); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +} + +const LANDING_CSS = ` + @keyframes heroPulse { + 0%,100% { opacity:.2; transform:translate(-50%,-50%) scale(1); } + 50% { opacity:.38; transform:translate(-50%,-50%) scale(1.12); } + } + @keyframes wordReveal { + from { opacity:0; transform:translateY(28px); } + to { opacity:1; transform:translateY(0); } + } + @keyframes fadeUp { + from { opacity:0; transform:translateY(16px); } + to { opacity:1; transform:translateY(0); } + } + @keyframes scrollBounce { + 0%,100% { transform:translateY(0); } + 50% { transform:translateY(5px); } + } + .lp-hero-orb { + position:absolute; top:50%; left:50%; + width:1100px; height:700px; + background:radial-gradient(ellipse at center, + rgba(245,197,24,.07) 0%, + rgba(37,99,235,.05) 45%, + transparent 70%); + border-radius:50%; + pointer-events:none; + animation:heroPulse 10s ease-in-out infinite; + } + .lp-word { display:block; animation:wordReveal .7s cubic-bezier(.16,1,.3,1) both; } + .lp-word-1 { animation-delay:.15s; } + .lp-word-2 { animation-delay:.3s; } + .lp-word-3 { animation-delay:.45s; } + .lp-nav { + position:fixed; top:0; left:0; right:0; z-index:100; + height:64px; display:flex; align-items:center; + justify-content:space-between; padding:0 40px; + background:rgba(7,8,15,.85); + backdrop-filter:blur(20px); -webkit-backdrop-filter:blur(20px); + border-bottom:1px solid rgba(255,255,255,.07); + } + .lp-nav__logo { + font-family:'Russo One',sans-serif; font-size:19px; + letter-spacing:.04em; text-transform:uppercase; + color:#eceef8; background:none; border:none; cursor:pointer; padding:0; + } + .lp-nav__logo span { color:#f5c518; } + .lp-nav__links { display:flex; align-items:center; gap:28px; } + .lp-nav-link { + font-family:'Barlow Condensed',sans-serif; font-size:13px; + font-weight:600; letter-spacing:.1em; text-transform:uppercase; + color:#7a85a8; text-decoration:none; cursor:pointer; + background:none; border:none; padding:0; transition:color .2s; + } + .lp-nav-link:hover { color:#eceef8; } + .lp-nav-signin { + padding:8px 18px; + background:rgba(245,197,24,.08); border:1px solid rgba(245,197,24,.35); + border-radius:6px; font-family:'Barlow Condensed',sans-serif; + font-size:13px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; + color:#f5c518; cursor:pointer; transition:background .2s, border-color .2s; + } + .lp-nav-signin:hover { background:rgba(245,197,24,.16); border-color:rgba(245,197,24,.6); } + .lp-container { width:100%; max-width:1100px; margin:0 auto; padding:0 24px; } + .lp-eyebrow { + font-family:'Barlow Condensed',sans-serif; font-size:11px; + font-weight:700; letter-spacing:.3em; text-transform:uppercase; + color:#f5c518; display:flex; align-items:center; justify-content:center; + gap:12px; margin-bottom:14px; + } + .lp-eyebrow::before,.lp-eyebrow::after { + content:''; display:block; width:36px; height:1px; + background:rgba(245,197,24,.4); + } + .lp-section-title { + font-family:'Russo One',sans-serif; + font-size:clamp(30px,5vw,48px); text-transform:uppercase; + letter-spacing:.02em; color:#eceef8; + text-align:center; margin-bottom:56px; + } + .lp-step-card { + background:#111426; border:1px solid rgba(255,255,255,.07); + border-radius:16px; padding:36px 28px; + position:relative; overflow:hidden; flex:1; min-width:260px; + transition:border-color .25s, transform .25s; + } + .lp-step-card::before { + content:''; position:absolute; top:0; left:0; right:0; height:2px; + background:linear-gradient(to right,transparent,rgba(245,197,24,.65),transparent); + } + .lp-step-card:hover { border-color:rgba(245,197,24,.22); transform:translateY(-4px); } + .lp-feature-card { + background:#111426; border:1px solid rgba(255,255,255,.07); + border-radius:12px; padding:28px 24px; + transition:border-color .25s, transform .25s; + } + .lp-feature-card:hover { border-color:rgba(245,197,24,.2); transform:translateY(-3px); } + .lp-lb-card { + background:#111426; border:1px solid rgba(255,255,255,.07); + border-radius:16px; overflow:hidden; max-width:580px; margin:0 auto; + } + .lp-lb-row { + display:flex; align-items:center; gap:14px; padding:13px 20px; + border-bottom:1px solid rgba(255,255,255,.06); transition:background .15s; + } + .lp-lb-row:last-child { border-bottom:none; } + .lp-lb-row:hover { background:rgba(255,255,255,.02); } + .lp-signin-card { + background:rgba(17,20,38,.96); border:1px solid rgba(255,255,255,.1); + border-radius:20px; padding:40px 36px; max-width:440px; margin:0 auto; + backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); + } + @media (max-width:768px) { + .lp-nav { padding:0 20px; } + .lp-nav-link.hide-mobile { display:none; } + .lp-signin-card { padding:28px 20px; } + .lp-step-card { min-width:100%; } + } + @media (max-width:500px) { + .lp-nav__links { gap:12px; } + } +`; + export default function Login() { - const [lbOpen, setLbOpen] = useState(false); + const navigate = useNavigate(); const authError = new URLSearchParams(window.location.search).get('error') === 'auth_failed'; + + const [authMode, setAuthMode] = useState('login'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [username, setUsername] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(''); + + const [lbGrade, setLbGrade] = useState('G3'); + const [lbEntries, setLbEntries] = useState([]); + const [lbLoading, setLbLoading] = useState(true); + + function resetForm() { setEmail(''); setPassword(''); setUsername(''); setFormError(''); } + + async function handleEmailAuth(e) { + e.preventDefault(); + setFormError(''); + setSubmitting(true); + try { + const isRegister = authMode === 'register'; + const body = isRegister + ? { email, password, ...(username.trim() ? { username: username.trim() } : {}) } + : { email, password }; + const res = await fetch(`${API}/auth/${isRegister ? 'register' : 'login'}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { setFormError(ERROR_MSGS[data.error] ?? 'Something went wrong. Please try again.'); return; } + sessionStorage.setItem('ranked_token', data.token); + navigate('/dashboard', { replace: true }); + } catch { + setFormError('Something went wrong. Check your connection.'); + } finally { + setSubmitting(false); + } + } + + useEffect(() => { + setLbLoading(true); + fetch(`${API}/leaderboard?grade=${lbGrade}&type=time`) + .then(r => r.json()) + .then(d => setLbEntries(Array.isArray(d) ? d.slice(0, 5) : [])) + .catch(() => setLbEntries([])) + .finally(() => setLbLoading(false)); + }, [lbGrade]); + + useEffect(() => { + if (authError) setTimeout(() => scrollTo('signin'), 400); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + function scrollTo(id) { + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }); + } + return ( <> - setLbOpen(false)} /> + - {/* Animated background */} + {/* Fixed animated background */}
{DIAMONDS.map((d, i) => ( -
+
))} {RINGS.map((r, i) => ( -
+
))} {SCANLINES.map((s, i) => ( -
+
))}
- {/* Page content */} -
-

Ontario Standardized Tests

- -
-

- Ranked - EQAO -

-

- Compete. Practice. Dominate your grade. -

+ {/* ── Sticky Nav ─────────────────────────────────────── */} + -
- {[ - { id: 'G3', label: 'Grade 3' }, - { id: 'G6', label: 'Grade 6' }, - { id: 'G9', label: 'Grade 9' }, - ].map(({ id, label }) => ( -
- {id} - {label} + {/* ── Page ───────────────────────────────────────────── */} +
+ + {/* ── HERO ─────────────────────────────────────────── */} +
+
+ +
+

+ Ontario Standardized Tests +

+ +

+ COMPETE. + PRACTICE. + DOMINATE. +

+ +

+ The ranked EQAO quiz platform for Ontario students. Practice Grade 3, 6, and 9 math — then go head-to-head against real opponents. +

+ +
+ + { e.currentTarget.style.borderColor = 'rgba(255,255,255,.5)'; }} + onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,.22)'; }} + > + How It Works +
- ))} -
-
- {authError && ( -

Sign-in failed — please try again.

- )} - - - Sign in with Google - -

Free · No credit card required

-
+ {/* Grade badges */} +
+ {[{ id: 'G3', label: 'Grade 3' }, { id: 'G6', label: 'Grade 6' }, { id: 'G9', label: 'Grade 9' }].map(({ id, label }) => ( +
+ {id} + {label} +
+ ))} +
+
-
- Ranked EQAO · Built for Ontario Students + {/* Scroll hint */} +
+ Scroll + + + +
+
+ + {/* ── HOW IT WORKS ─────────────────────────────────── */} +
+
+

Get Started

+

How It Works

+ +
+ {[ + { + num: '01', + icon: ( + + + + + ), + title: 'Sign In', + desc: "Create a free account with Google or email. Pick your grade — G3, G6, or G9 — and you're ready to start.", + }, + { + num: '02', + icon: ( + + + + + ), + title: 'Practice', + desc: 'Answer 22 EQAO-style math questions across two stages. Every question must be correct to advance — no partial credit, no shortcuts.', + }, + { + num: '03', + icon: ( + + + + + + + + + ), + title: 'Compete', + desc: 'Join the ranked queue and race against a real opponent. Your ELO rises or falls on the result. Climb from Bronze all the way to Diamond.', + }, + ].map((step, i) => ( +
+ + {step.num} + +
+ {step.icon} +
+

+ {step.title} +

+

+ {step.desc} +

+
+ ))} +
+
+
+ + {/* ── FEATURES ─────────────────────────────────────── */} +
+
+

The Platform

+

Why Ranked EQAO?

+ +
+ {[ + { + icon: , + title: 'Real EQAO-Style Questions', + desc: 'Randomized values on every attempt — same problem structure, different numbers. You can\'t just memorize answers.', + }, + { + icon: , + title: 'Live 1v1 Matchmaking', + desc: 'Race against real students in real time. First to correctly complete all questions wins the match and earns ELO.', + }, + { + icon: , + title: 'ELO Ranking System', + desc: 'Climb from Bronze through Silver, Gold, Platinum, and Diamond. Your rating reflects actual performance against real opponents.', + }, + { + icon: , + title: 'Best Time Leaderboard', + desc: 'Every second counts. Your fastest completion time is tracked separately on the global leaderboard.', + }, + { + icon: , + title: 'Grade-Specific Content', + desc: 'Separate queues, leaderboards, and ELO ratings for Grade 3, 6, and 9. Difficulty scales with each grade level.', + }, + { + icon: , + title: '100% Free', + desc: 'No ads, no paywalls, no subscriptions. Ranked EQAO is completely free for every Ontario student.', + }, + ].map((f, i) => ( +
+
+ {f.icon} +
+

+ {f.title} +

+

+ {f.desc} +

+
+ ))} +
+
+
+ + {/* ── LEADERBOARD PREVIEW ───────────────────────────── */} +
+
+

Rankings

+

Top Players

+ + {/* Grade tabs */} +
+ {['G3', 'G6', 'G9'].map(g => ( + + ))} +
+ +
+ {/* Table header */} +
+ {['#', 'Player', 'Best Time'].map((h, i) => ( + {h} + ))} +
+ + {lbLoading ? ( +
+ {[0, 1, 2, 3, 4].map(i => ( +
+ ))} +
+ ) : lbEntries.length === 0 ? ( +
+
🏆
+

No scores yet — be the first!

+
+ ) : ( + lbEntries.map((entry, i) => { + const rc = i < 3 ? RANK_COLORS[i] : '#7a85a8'; + const playerLabel = entry.username ? `@${entry.username}` : (entry.displayName ?? 'Player'); + const initial = playerLabel[0]?.toUpperCase() ?? '?'; + return ( +
+ + {i + 1} + +
+ {entry.avatarUrl + ? {playerLabel} + :
{initial}
+ } + + {playerLabel} + +
+ + {fmtTime(entry.bestTime)} + +
+ ); + }) + )} +
+ +
+

+ Think you can crack the top 5? +

+ +
+
+
+ + {/* ── SIGN IN ───────────────────────────────────────── */} +
+
+

Join the Arena

+

+ Ready to Play? +

+
+ +
+
+ + {authError &&

Sign-in failed — please try again.

} + + {/* Google button */} + + Sign in with Google + + + {/* Divider */} +
+
+ — or — +
+
+ + {/* Mode toggle */} +
+ {['login', 'register'].map(mode => ( + + ))} +
+ + {/* Email form */} +
+ setEmail(e.target.value)} style={INPUT_STYLE} autoComplete="email" /> + setPassword(e.target.value)} style={INPUT_STYLE} autoComplete={authMode === 'login' ? 'current-password' : 'new-password'} /> + {authMode === 'register' && ( + setUsername(e.target.value)} + pattern="^[a-zA-Z0-9_]{3,20}$" + title="3–20 characters: letters, numbers, underscores only" + style={INPUT_STYLE} + autoComplete="username" + /> + )} + + {formError &&

{formError}

} +
+ +

+ Free · No credit card required +

+
+
+
+ + {/* ── FOOTER ───────────────────────────────────────── */} +
+
+ + RANKEDEQAO + +
+ {[ + { label: 'How It Works', href: '#how-it-works' }, + { label: 'About', href: '/about' }, + { label: 'FAQ', href: '/faq' }, + { label: 'Sign In', href: null, onClick: () => scrollTo('signin') }, + ].map(({ label, href, onClick }) => + href ? ( + e.currentTarget.style.color = '#eceef8'} + onMouseLeave={e => e.currentTarget.style.color = '#7a85a8'} + > + {label} + + ) : ( + + ) + )} +
+

+ Ranked EQAO — Built for Ontario students  ·  Not affiliated with EQAO or the Ontario government. +

+
-
- +
); } @@ -141,22 +779,10 @@ export default function Login() { function GoogleIcon() { return ( ); -} \ No newline at end of file +} diff --git a/frontend/src/pages/Queue.jsx b/frontend/src/pages/Queue.jsx index a64b773..4cc7608 100644 --- a/frontend/src/pages/Queue.jsx +++ b/frontend/src/pages/Queue.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate, useLocation, Navigate } from 'react-router-dom'; const VALID_GRADES = new Set(['G3', 'G6', 'G9']); @@ -9,21 +9,380 @@ function authHeaders() { return token ? { Authorization: `Bearer ${token}` } : {}; } -function FindingScreen({ elapsed, onBack }) { +function getNameFromToken() { + try { + const token = sessionStorage.getItem('ranked_token'); + if (!token) return 'You'; + const payload = JSON.parse(atob(token.split('.')[1])); + return payload.name ?? 'You'; + } catch { + return 'You'; + } +} + +function getAvatarFromToken() { + try { + const token = sessionStorage.getItem('ranked_token'); + if (!token) return null; + const payload = JSON.parse(atob(token.split('.')[1])); + return payload.picture ?? null; + } catch { + return null; + } +} + +function MatchFoundScreen({ myName, myAvatar, opponentName, opponentAvatar, grade, onDone }) { + const [count, setCount] = useState(5); + const [phase, setPhase] = useState('intro'); // intro → reveal → countdown + + useEffect(() => { + const t1 = setTimeout(() => setPhase('reveal'), 400); + const t2 = setTimeout(() => setPhase('countdown'), 1200); + return () => { clearTimeout(t1); clearTimeout(t2); }; + }, []); + + useEffect(() => { + if (phase !== 'countdown') return; + if (count <= 0) { onDone(); return; } + const id = setTimeout(() => setCount(c => c - 1), 1000); + return () => clearTimeout(id); + }, [count, phase, onDone]); + + const countColor = count <= 1 ? '#ff3b30' : count <= 2 ? '#ff9500' : count <= 3 ? '#ffcc00' : '#4fc3f7'; + const countGlow = count <= 1 ? '#ff3b3088' : count <= 2 ? '#ff950088' : count <= 3 ? '#ffcc0088' : '#4fc3f788'; + + return ( +
+ + + {/* Scanline overlay */} +
+
+ {/* subtle grid */} +
+
+ + {/* Header */} +
+ Match Found +
+ + {/* Players row */} +
+ + {/* You */} +
+
+ {myAvatar + ? {myName} + : {(myName?.[0] ?? '?').toUpperCase()} + } +
+ {myName} + You +
+ + {/* VS + countdown */} +
+ VS + + {phase === 'countdown' && ( +
+ {/* pulse rings */} +
+
+
+ {count} +
+
+ )} +
+ + {/* Opponent */} +
+
+ {opponentAvatar + ? {opponentName} + : {(opponentName?.[0] ?? '?').toUpperCase()} + } +
+ {opponentName} + Opponent +
+
+ + {/* Divider line */} +
+ + {/* Grade tag */} +
+ Ranked · Grade {grade?.replace('G', '') ?? '?'} +
+
+ ); +} + +const QUEUE_DIAMONDS = [ + { left: '8%', top: '15%', size: 10, dur: '12s', delay: '0s' }, + { left: '22%', top: '68%', size: 7, dur: '15s', delay: '2s' }, + { left: '52%', top: '20%', size: 13, dur: '11s', delay: '1s' }, + { left: '68%', top: '72%', size: 9, dur: '14s', delay: '3s' }, + { left: '84%', top: '35%', size: 11, dur: '13s', delay: '0.5s' }, + { left: '40%', top: '82%', size: 8, dur: '10s', delay: '4s' }, +]; + +function FindingScreen({ elapsed, grade, onBack }) { const [dots, setDots] = useState(''); - useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []); + const [playersOnline, setPlayersOnline] = useState(null); + + useEffect(() => { + const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); + return () => clearInterval(t); + }, []); + + useEffect(() => { + const fetchCount = () => { + fetch(`${API}/online-count`) + .then(r => r.json()) + .then(d => setPlayersOnline(d.online ?? 0)) + .catch(() => {}); + }; + fetchCount(); + const t = setInterval(fetchCount, 30000); + return () => clearInterval(t); + }, []); + const minutes = Math.floor(elapsed / 60); const seconds = elapsed % 60; + const gradeNum = grade?.replace('G', '') ?? '?'; + return ( -
-
-

Searching for opponent{dots}

-

Searching all players...

-
- Time elapsed: {minutes}:{String(seconds).padStart(2, '0')} +
+ + + {/* Arena background */} +
+
+
+ {QUEUE_DIAMONDS.map((d, i) => ( +
+ ))} +
+
+ + {/* Content */} +
+ + {/* Two counter-rotating rings */} +
+
+
+
+
+ + {/* Grade label */} +

+ Grade {gradeNum} · Ranked +

+ + {/* Heading */} +

+ Searching for opponent{dots} +

+ + {/* Players online */} +

+ {playersOnline !== null ? `${playersOnline.toLocaleString()} ${playersOnline === 1 ? 'player' : 'players'} online` : 'finding players...'} +

+ + {/* Elapsed timer — large gold */} +
+ {String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')} +
+ + {/* Back button */} +
- -
); } @@ -33,9 +392,10 @@ export default function Queue() { const { state } = useLocation(); const grade = state?.grade ?? null; - const [myName, setMyName] = useState(''); - const [myAvatar, setMyAvatar] = useState(null); + const [myName, setMyName] = useState(() => getNameFromToken()); + const [myAvatar, setMyAvatar] = useState(() => getAvatarFromToken()); const [queueElapsed, setQueueElapsed] = useState(0); + const [matchData, setMatchData] = useState(null); const pollRef = useRef(null); const joinedRef = useRef(false); @@ -43,11 +403,12 @@ export default function Queue() { const leaveTimerRef = useRef(null); const leavingRef = useRef(false); - // Elapsed timer + // Elapsed timer — stop once matched useEffect(() => { + if (matchData) return; const id = setInterval(() => setQueueElapsed(e => e + 1), 1000); return () => clearInterval(id); - }, []); + }, [matchData]); // Fetch own user info useEffect(() => { @@ -115,7 +476,7 @@ export default function Queue() { if (!data || leavingRef.current) return; if (data.status === 'matched') { clearInterval(pollRef.current); - navigateToGame(data); + showMatchup(data); } else if (data.status === 'not_in_queue') { clearInterval(pollRef.current); fetch(`${API}/queue/join`, { @@ -130,7 +491,7 @@ export default function Queue() { .then(d => { if (!d || leavingRef.current) return; if (d.status === 'waiting') pollQueue(); - else if (d.status === 'matched') navigateToGame(d); + else if (d.status === 'matched') showMatchup(d); }) .catch(() => {}); } @@ -144,29 +505,46 @@ export default function Queue() { .then(r => r.json()) .then(data => { if (!leavingRef.current && data.status === 'matched') { - navigateToGame(data); + showMatchup(data); } }) .catch(() => {}); } - function navigateToGame(data) { + function showMatchup(data) { clearInterval(pollRef.current); completedRef.current = true; + setMatchData(data); + } + + const goToGame = useCallback(() => { navigate('/ranked-game', { state: { - gameId: data.gameId, - questions: data.questions, - opponentName: data.opponent.username ?? data.opponent.displayName, - opponentAvatar: data.opponent.avatarUrl, + gameId: matchData.gameId, + questions: matchData.questions, + opponentName: matchData.opponent.username ?? matchData.opponent.displayName, + opponentAvatar: matchData.opponent.avatarUrl, myName, myAvatar, grade, }, }); - } + }, [matchData, myName, myAvatar, grade, navigate]); if (!VALID_GRADES.has(grade)) return ; - return navigate('/dashboard')} />; + if (matchData) { + return ( + + ); + } + + return navigate('/dashboard')} />; } diff --git a/frontend/src/pages/RankedGame.jsx b/frontend/src/pages/RankedGame.jsx index f3058ef..f7be519 100644 --- a/frontend/src/pages/RankedGame.jsx +++ b/frontend/src/pages/RankedGame.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useNavigate, useLocation, Navigate } from 'react-router-dom'; +import treeBg from '../assets/tree_bg_svg.svg'; const STAGE_SIZE = 11; const API = import.meta.env.VITE_API_URL; @@ -22,89 +23,135 @@ function authHeaders() { return token ? { Authorization: `Bearer ${token}` } : {}; } -function OpponentProgressStrip({ progress, total, name, dced, disconnected }) { +function SpeechBubbleIcon({ size = 44 }) { + return ( + + + + + ); +} + +function GlobalStyles() { + return ( + + ); +} + +function OpponentProgressStrip({ progress, total, name, dced, disconnected, emoteBubble, stageLabel }) { + const prevProgressRef = useRef(progress); + const [nameFlashing, setNameFlashing] = useState(false); + + useEffect(() => { + if (progress > prevProgressRef.current) { + setNameFlashing(true); + const t = setTimeout(() => setNameFlashing(false), 500); + prevProgressRef.current = progress; + return () => clearTimeout(t); + } + prevProgressRef.current = progress; + }, [progress]); + const items = []; for (let qi = 0; qi < total; qi++) { const filled = qi < progress; items.push( -
{qi + 1}
+
{qi + 1}
); if (qi < total - 1) { - items.push( -
- ); + items.push(
); } } return ( -
-
- {name} - {dced && DC'd} - {!dced && disconnected && disconnected?} -
-
- {items} +
+ {emoteBubble && ( +
+ {emoteBubble.text} +
+
+ )} +
+
+ {name} + {dced && DC'd} + {!dced && disconnected && disconnected?} + {stageLabel && Stage {stageLabel}} +
+
+ {items} +
); } function ProgressStrip({ total, current, answers, onJump }) { + const [fillCounts, setFillCounts] = useState({}); + const prevAnswersRef = useRef({}); + + useEffect(() => { + const updates = {}; + for (let qi = 0; qi < total; qi++) { + if (answers[qi] != null && prevAnswersRef.current[qi] == null) updates[qi] = true; + } + if (Object.keys(updates).length > 0) { + setFillCounts(prev => { + const next = { ...prev }; + for (const qi of Object.keys(updates)) next[Number(qi)] = (prev[Number(qi)] || 0) + 1; + return next; + }); + } + prevAnswersRef.current = answers; + }, [answers, total]); + const items = []; for (let qi = 0; qi < total; qi++) { const active = qi === current, answered = answers[qi] != null, filled = answered; + const popCount = fillCounts[qi] || 0; items.push( - + ); if (qi < total - 1) items.push(
); } return
{items}
; } -function PlayerChip({ name, avatar, reverse }) { - const pic = avatar - ? - :
{name?.[0]?.toUpperCase() ?? '?'}
; - const label = {name}; - return ( -
- {pic}{label} -
- ); -} +function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, opponentProgress, opponentName, opponentDced, opponentConnected, onForfeit, emoteBubble, stageNum, timerDisplay, retryBanner, opponentStage, grade }) { + const [pulsingIdx, setPulsingIdx] = useState(null); + const [hoverIdx, setHoverIdx] = useState(null); + const [timerFlash, setTimerFlash] = useState(false); + const lastPulseRef = useRef(-1); -function StageIntro({ grade, myName, myAvatar, opponentName, opponentAvatar, onNext }) { - const [count, setCount] = useState(5); - const gradeNum = grade?.replace('G', '') ?? ''; - - useEffect(() => { - const id = setInterval(() => { - setCount(c => { - if (c <= 1) { clearInterval(id); onNext(); return 0; } - return c - 1; - }); - }, 1000); - return () => clearInterval(id); - }, [onNext]); - - return ( -
-

Ranked Match

-

EQAO Grade {gradeNum}

-
- - vs - -
-

First to finish all {STAGE_SIZE} questions wins.

-
{count}
-
- ); -} - -function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, opponentProgress, opponentName, opponentDced, opponentConnected, onForfeit }) { useEffect(() => { const handler = (e) => { + if (e.target.tagName === 'INPUT') return; const i = +e.key - 1; if (i >= 0 && i < q.choices.length) { onSelect(i); return; } if ((e.key === 'Enter' || e.key === 'ArrowRight') && qIdx < STAGE_SIZE - 1) onNext(); @@ -114,24 +161,73 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext return () => window.removeEventListener('keydown', handler); }, [q.choices.length, qIdx, onSelect, onNext, onBack]); + useEffect(() => { + if (!timerDisplay) return; + const parts = timerDisplay.split(':'); + if (parts.length !== 2) return; + const totalSeconds = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + if (totalSeconds > 0 && totalSeconds % 30 === 0 && totalSeconds !== lastPulseRef.current) { + lastPulseRef.current = totalSeconds; + setTimerFlash(true); + const t = setTimeout(() => setTimerFlash(false), 500); + return () => clearTimeout(t); + } + }, [timerDisplay]); + return ( -
-
-
-

Question {qIdx + 1} of {STAGE_SIZE}

-
- You -
+
+
+
+ {retryBanner != null && ( +
+ {retryBanner === 1 ? '1 answer incorrect' : `${retryBanner} answers incorrect`} — fix them and resubmit +
+ )} +
+
+
+

+ Stage {stageNum ?? 1} · Question {qIdx + 1} of {STAGE_SIZE} +

+ {grade && ( + + {grade} + + )} +
+ {timerDisplay && {timerDisplay}} +
+
+
+ You +
+
+
-
-
+

{q.question}

{q.choices.map((choice, i) => { const sel = selected === i; + const hov = hoverIdx === i && !sel; return ( - ); @@ -141,11 +237,11 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
- {qIdx > 0 && } + {qIdx > 0 && } {qIdx === STAGE_SIZE - 1 ? ( - + ) : ( - + )}
@@ -154,10 +250,326 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext ); } +function SubmittingOverlay() { + return ( +
+
Done.
+
+ ); +} + +function EmotePicker({ open, onToggle, onSend, onQuickChat }) { + return ( +
+ {open && ( +
+ {/* Section 1: 2×4 emote grid */} +
+ {Array.from({ length: 8 }, (_, i) => ( + + ))} +
+ +
+ + {/* Section 2: 1×4 emoji row */} +
+ {Array.from({ length: 4 }, (_, i) => ( + + ))} +
+ +
+ + {/* Section 3: 2×4 quick-chat grid */} +
+ {['1','2','3','4','5','6','7','8'].map((label) => ( + + ))} +
+
+ )} + + {/* Toggle button */} + +
+ ); +} + +function WrongAnswerCard({ notif, onDismiss }) { + const [fading, setFading] = useState(false); + const dismissRef = useRef(onDismiss); + dismissRef.current = onDismiss; + + useEffect(() => { + const t1 = setTimeout(() => setFading(true), 7500); + const t2 = setTimeout(() => dismissRef.current(notif.id), 8000); + return () => { clearTimeout(t1); clearTimeout(t2); }; + }, [notif.id]); + + return ( +
+ +
+ {notif.opponentName} got this question WRONG! +
+
+ {notif.question} +
+
+ {notif.choices.map((choice, i) => ( +
+ {choice} +
+ ))} +
+
+ ); +} + +function WrongAnswerNotifications({ notifications, onDismiss }) { + if (!notifications.length) return null; + return ( +
+ {notifications.map(n => ( +
+ +
+ ))} +
+ ); +} + +function MinecraftChat({ messages, myUserId, gameId, emotePickerOpen, onChatOpen }) { + const [inputActive, setInputActive] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [flashKey, setFlashKey] = useState(null); + const inputRef = useRef(null); + const prevLenRef = useRef(0); + const inputActiveRef = useRef(false); + inputActiveRef.current = inputActive; + const onChatOpenRef = useRef(onChatOpen); + onChatOpenRef.current = onChatOpen; + + // Close chat when emote picker opens + useEffect(() => { + if (emotePickerOpen) { + setInputActive(false); + setInputValue(''); + } + }, [emotePickerOpen]); + + useEffect(() => { + if (messages.length > prevLenRef.current && !inputActiveRef.current) { + const newest = messages[messages.length - 1]; + if (newest && newest.userId !== myUserId) { + const k = `${newest.sentAt}-${newest.userId}`; + setFlashKey(k); + setTimeout(() => setFlashKey(null), 1000); + } + } + prevLenRef.current = messages.length; + }, [messages.length, myUserId]); + + useEffect(() => { + function handler(e) { + if (e.key === '/' && e.target.tagName !== 'INPUT' && !inputActiveRef.current) { + e.preventDefault(); + setInputActive(true); + onChatOpenRef.current?.(); + } + } + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + useEffect(() => { + if (inputActive) inputRef.current?.focus(); + }, [inputActive]); + + async function handleSend() { + const trimmed = inputValue.trim(); + setInputActive(false); + setInputValue(''); + if (!trimmed) return; + try { + await fetch(`${API}/game/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ gameId, message: trimmed }), + }); + } catch {} + } + + function handleKeyDown(e) { + if (e.key === 'Enter') { e.preventDefault(); handleSend(); } + if (e.key === 'Escape') { setInputActive(false); setInputValue(''); } + } + + const shown = messages.slice(-6); + + return ( +
+ {shown.map((msg, i) => { + const isMe = msg.userId === myUserId; + const k = `${msg.sentAt}-${msg.userId}`; + const isFlash = k === flashKey; + return ( +
+ + {msg.username} + : {msg.text} + +
+ ); + })} + {inputActive ? ( + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { setInputActive(false); setInputValue(''); }} + placeholder="Press Enter to send, Esc to cancel" + maxLength={120} + style={{ + width: '100%', + background: 'rgba(0,0,0,0.7)', + color: '#fff', + border: 'none', + outline: 'none', + padding: '6px 8px', + fontFamily: 'system-ui,sans-serif', + fontSize: 13, + boxSizing: 'border-box', + }} + /> + ) : ( +
{ setInputActive(true); onChatOpenRef.current?.(); }} + style={{ + width: '100%', + background: 'rgba(0,0,0,0.55)', + color: 'rgba(255,255,255,0.4)', + padding: '8px 12px', + borderRadius: 4, + fontSize: 13, + fontFamily: 'system-ui,sans-serif', + cursor: 'text', + boxSizing: 'border-box', + userSelect: 'none', + }} + > + Press / to chat... +
+ )} +
+ ); +} + function DefeatOverlay({ opponentName, onViewResults }) { return ( -
-
+
+
💀

Defeated!

{opponentName} finished before you.

@@ -167,7 +579,7 @@ function DefeatOverlay({ opponentName, onViewResults }) { ); } -function WaitingScreen({ opponentName, opponentProgress, opponentDced, opponentConnected, onForfeit }) { +function WaitingScreen({ opponentName, opponentProgress, opponentDced, opponentConnected, onForfeit, emoteBubble, opponentStage }) { return (
{opponentDced @@ -181,57 +593,287 @@ function WaitingScreen({ opponentName, opponentProgress, opponentDced, opponentC {opponentDced ? 'Awarding victory...' : 'You finished! Now waiting for your opponent to complete.'}

- +
-
); } -function CompleteScreen({ won, opponentName, duration, eloChange, opponentDced, youDced, onDashboard }) { - const subtitle = opponentDced && won - ? `${opponentName} disconnected. Victory by default.` - : youDced && !won - ? `You disconnected. ${opponentName} wins.` - : won - ? `You beat ${opponentName}! Well played.` - : `${opponentName} beat you. Better luck next time.`; +function ForfeitConfirmModal({ onConfirm, onCancel }) { + return ( +
+
+
🏳️
+

Forfeit?

+

This counts as a loss and will reduce your ELO.

+
+ + +
+
+
+ ); +} + +function CompleteScreen({ won, opponentName, duration, eloChange, newElo, opponentDced, youDced, onDashboard, onPlayAgain }) { + const [reveal, setReveal] = useState(false); + useEffect(() => { + const t = setTimeout(() => setReveal(true), 60); + return () => clearTimeout(t); + }, []); + + const fmtDuration = duration > 0 + ? `${String(Math.floor(duration / 1000 / 60)).padStart(2, '0')}:${String(Math.floor(duration / 1000) % 60).padStart(2, '0')}` + : '—'; + + const accent = won ? '#f5c518' : '#dc2626'; + const accentGlow = won ? 'rgba(245,197,24,0.45)' : 'rgba(220,38,38,0.5)'; + const accentDim = won ? 'rgba(245,197,24,0.09)' : 'rgba(220,38,38,0.09)'; + const accentHov = won ? 'rgba(245,197,24,0.18)' : 'rgba(220,38,38,0.18)'; + + const eloVal = eloChange !== 0 ? `${eloChange > 0 ? '+' : ''}${eloChange}` : '—'; + const eloColor = eloChange > 0 ? '#22c55e' : eloChange < 0 ? '#ef4444' : '#555'; + + const stats = [ + { label: 'Time', value: fmtDuration, color: '#b8bcd8' }, + { label: 'ELO', value: eloVal, color: eloColor }, + { label: 'Rating', value: newElo != null ? newElo.toLocaleString() : '—', color: '#b8bcd8' }, + ]; + + const opponentLine = won + ? (opponentDced ? 'OPPONENT DISCONNECTED' : 'YOU DEFEATED') + : 'DEFEATED BY'; return ( -
-
-
{won ? (opponentDced ? '🔌' : '🏆') : '💀'}
-

{won ? 'Victory!' : 'Defeated!'}

-

{subtitle}

- {duration > 0 && ( -
- ⏱ {Math.floor(duration / 1000 / 60)}:{String(Math.floor(duration / 1000) % 60).padStart(2, '0')} +
+ {/* Atmospheric top-glow */} +
+ {/* Hard vignette on edges */} +
+ {/* Subtle scanline texture */} +
+ + {/* Flash on mount */} +
+ + {/* Content */} +
+ + {/* Eyebrow */} +
+ Ranked Match +
+ + {/* Icon */} +
+ {won ? '🏆' : '💀'} +
+ + {/* Title */} +
+ {won ? 'VICTORY' : 'DEFEAT'} +
+ + {/* Accent rule */} +
+ + {/* Opponent */} +
+
+ {opponentLine}
- )} - {eloChange !== 0 &&
0 ? '#1b8a2d' : '#c62828' }}>ELO {eloChange > 0 ? '+' : ''}{eloChange}
} - +
+ {opponentName} +
+
+ + {/* Stats */} +
+ {stats.map(s => ( +
+
+ {s.label} +
+
+ {s.value} +
+
+ ))} +
+ + {/* Actions */} +
+ + +
); } +function StageTransition({ onNext }) { + useEffect(() => { + const id = setTimeout(onNext, 1500); + return () => clearTimeout(id); + }, [onNext]); + + return ( +
+

Stage 1 Complete

+

Stage 2 — Get Ready

+
+ ); +} + export default function RankedGame() { const navigate = useNavigate(); const { state } = useLocation(); - if (!state || !state.gameId || !state.questions) { + // Router state is lost on hard reload — try to reconstruct from sessionStorage + let effectiveState = state; + if (!effectiveState?.gameId || !effectiveState?.questions) { + try { + const savedId = sessionStorage.getItem('ranked_active_game'); + if (savedId) { + const savedInit = sessionStorage.getItem(`ranked_init_${savedId}`); + if (savedInit) effectiveState = { gameId: savedId, ...JSON.parse(savedInit) }; + } + } catch {} + } + + if (!effectiveState?.gameId || !effectiveState?.questions) { return ; } - const { gameId, questions: initialQuestions, opponentName: initOppName, opponentAvatar: initOppAvatar, myName: initMyName, myAvatar: initMyAvatar, grade } = state; + const { gameId, questions: initialQuestionsObj, opponentName: initOppName, opponentAvatar: initOppAvatar, myName: initMyName, myAvatar: initMyAvatar, grade } = effectiveState; - const [phase, setPhase] = useState('intro'); - const [questions] = useState(initialQuestions); - const [qIdx, setQIdx] = useState(0); - const [answers, setAnswers] = useState({}); - const [selected, setSelected] = useState(null); + const [phase, setPhase] = useState(() => { + try { + const saved = sessionStorage.getItem('rg_phase_' + gameId); + if (saved && saved !== 'intro1') return saved; + } catch {} + return 'intro1'; + }); + const [stage, setStage] = useState(() => { + try { const v = sessionStorage.getItem('ranked_stage_' + gameId); return v ? parseInt(v, 10) : 1; } catch { return 1; } + }); + const [stage1Questions] = useState(() => initialQuestionsObj?.stage1 ?? []); + const [stage2Questions] = useState(() => initialQuestionsObj?.stage2 ?? []); + const questions = stage === 1 ? stage1Questions : stage2Questions; + const [qIdx, setQIdx] = useState(() => { + try { const v = sessionStorage.getItem('ranked_qidx_' + gameId); return v ? parseInt(v, 10) : 0; } catch { return 0; } + }); + const [answers, setAnswers] = useState(() => { + try { const v = sessionStorage.getItem('ranked_answers_' + gameId); return v ? JSON.parse(v) : {}; } catch { return {}; } + }); + const [selected, setSelected] = useState(() => { + try { + const savedAnswers = sessionStorage.getItem('ranked_answers_' + gameId); + const savedQIdx = sessionStorage.getItem('ranked_qidx_' + gameId); + const parsedAnswers = savedAnswers ? JSON.parse(savedAnswers) : {}; + const parsedQIdx = savedQIdx ? parseInt(savedQIdx, 10) : 0; + return parsedAnswers[parsedQIdx] ?? null; + } catch { return null; } + }); const [opponentProgress, setOpponentProgress] = useState(0); + const [opponentStage, setOpponentStage] = useState(1); + const opponentStageRef = useRef(1); const [opponentName] = useState(initOppName); const [opponentAvatar] = useState(initOppAvatar); const [playerWon, setPlayerWon] = useState(null); @@ -239,23 +881,115 @@ export default function RankedGame() { const [startTime] = useState(() => Date.now()); const [duration, setDuration] = useState(0); const [eloChange, setEloChange] = useState(0); + const [newElo, setNewElo] = useState(null); const [opponentDced, setOpponentDced] = useState(false); const [youDced, setYouDced] = useState(false); const [opponentConnected, setOpponentConnected] = useState(true); + const [showForfeitConfirm, setShowForfeitConfirm] = useState(false); + const [wrongCount, setWrongCount] = useState(0); + const [resultsViewQ, setResultsViewQ] = useState(0); + + // Emote state + const [emotePickerOpen, setEmotePickerOpen] = useState(false); + const [opponentEmoteBubble, setOpponentEmoteBubble] = useState(null); + const lastDisplayedEmoteRef = useRef(null); + + // Wrong answer notifications + const [wrongAnswerNotifs, setWrongAnswerNotifs] = useState([]); + const opponentPrevStateRef = useRef({ currentQuestion: 0, correctAnswers: 0 }); + + const [chatMessages, setChatMessages] = useState([]); + const [myUserId] = useState(() => { + try { + const token = sessionStorage.getItem('ranked_token'); + if (!token) return null; + return JSON.parse(atob(token.split('.')[1])).userId; + } catch { return null; } + }); const pollRef = useRef(null); const completedRef = useRef(false); const submittedRef = useRef(false); + const [retryBanner, setRetryBanner] = useState(null); + const [timerDisplay, setTimerDisplay] = useState('00:00'); + const timerRef = useRef(null); + const timerStartRef = useRef(null); + const stageRef = useRef(1); + stageRef.current = stage; + const questionsRef = useRef([]); + questionsRef.current = questions; + const ssRestoredRef = useRef(sessionStorage.getItem('ranked_qidx_' + gameId) !== null); + const firstPollDoneRef = useRef(false); useEffect(() => { - return () => clearInterval(pollRef.current); - }, []); + sessionStorage.setItem('ranked_answers_' + gameId, JSON.stringify(answers)); + }, [answers]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + sessionStorage.setItem('ranked_qidx_' + gameId, String(qIdx)); + }, [qIdx]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + sessionStorage.setItem('ranked_stage_' + gameId, String(stage)); + }, [stage]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + sessionStorage.setItem('rg_phase_' + gameId, phase); + }, [phase]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (phase === 'done') { + const id = setTimeout(() => setPhase('waiting'), 1200); + return () => clearTimeout(id); + } + }, [phase]); + + useEffect(() => { + if (!sessionStorage.getItem('ranked_init_' + gameId)) { + sessionStorage.setItem('ranked_active_game', gameId); + sessionStorage.setItem('ranked_init_' + gameId, JSON.stringify({ + questions: initialQuestionsObj, + opponentName: initOppName, + opponentAvatar: initOppAvatar, + myName: initMyName, + myAvatar: initMyAvatar, + grade, + })); + } + const savedTimerStart = sessionStorage.getItem('ranked_timer_start_' + gameId); + const savedPhaseForTimer = sessionStorage.getItem('rg_phase_' + gameId); + if (savedTimerStart) { + timerStartRef.current = parseInt(savedTimerStart, 10); + } else if (savedPhaseForTimer && savedPhaseForTimer !== 'intro1') { + // Restored session that's past the intro — start timer now + timerStartRef.current = Date.now(); + sessionStorage.setItem('ranked_timer_start_' + gameId, String(timerStartRef.current)); + } + // else: timer starts when user clicks Next on intro1, not on mount + timerRef.current = setInterval(() => { + if (!timerStartRef.current) return; + const ms = Date.now() - timerStartRef.current; + const s = Math.floor(ms / 1000); + setTimerDisplay(`${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`); + }, 1000); + pollGameStatus(); + return () => { + clearInterval(pollRef.current); + clearInterval(timerRef.current); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + function clearGameStorage() { + ['answers', 'qidx', 'stage', 'timer_start', 'init'].forEach(k => + sessionStorage.removeItem(`ranked_${k}_${gameId}`) + ); + sessionStorage.removeItem('rg_phase_' + gameId); + sessionStorage.removeItem('rg_timerstart_' + gameId); + if (sessionStorage.getItem('ranked_active_game') === gameId) + sessionStorage.removeItem('ranked_active_game'); + } function submitProgress(currentQuestion, correctAnswers, finished, timeSpentMs) { fetch(`${API}/game/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, - body: JSON.stringify({ gameId, currentQuestion, correctAnswers, finished, timeSpentMs }), + body: JSON.stringify({ gameId, currentQuestion, correctAnswers, finished, timeSpentMs, stage: stageRef.current }), }).catch(() => {}); } @@ -270,13 +1004,17 @@ export default function RankedGame() { if (data.youDced) setYouDced(true); if (data.opponentConnected === false) setOpponentConnected(false); else if (data.opponentConnected === true && !data.opponentDced) setOpponentConnected(true); + if (data.status === 'completed') { clearInterval(pollRef.current); + clearInterval(timerRef.current); const you = data.players.you; const won = data.youWon === true; completedRef.current = true; setDuration(you.timeSpentMs || (Date.now() - startTime)); setEloChange(you.eloChange || 0); + setNewElo(you.newElo ?? null); + clearGameStorage(); setPlayerWon(won); if (won) { setPhase('complete'); @@ -285,7 +1023,59 @@ export default function RankedGame() { } } else { const opp = data.players.opponent; + const prevOpp = opponentPrevStateRef.current; + + // Wrong answer detection + if ( + opp.currentQuestion === prevOpp.currentQuestion + 1 && + opp.correctAnswers === prevOpp.correctAnswers + ) { + const wrongQ = questionsRef.current[prevOpp.currentQuestion]; + if (wrongQ) { + const id = Date.now() + Math.random(); + setWrongAnswerNotifs(prev => [...prev, { + id, + question: wrongQ.question, + correctIndex: wrongQ.correct, + choices: wrongQ.choices, + opponentName, + timestamp: Date.now(), + }].slice(-3)); + setTimeout(() => { + setWrongAnswerNotifs(prev => prev.filter(n => n.id !== id)); + }, 8200); + } + } + opponentPrevStateRef.current = { currentQuestion: opp.currentQuestion, correctAnswers: opp.correctAnswers }; + setOpponentProgress(opp.currentQuestion + 1); + const newOppStage = opp.currentStage ?? 1; + if (newOppStage !== opponentStageRef.current) { + opponentStageRef.current = newOppStage; + setOpponentStage(newOppStage); + opponentPrevStateRef.current = { currentQuestion: 0, correctAnswers: 0 }; + } + + // Emote bubble + if (opp.lastEmote && opp.lastEmote !== lastDisplayedEmoteRef.current) { + lastDisplayedEmoteRef.current = opp.lastEmote; + const key = Date.now(); + setOpponentEmoteBubble({ text: opp.lastEmote, key }); + setTimeout(() => setOpponentEmoteBubble(null), 3100); + } + + if (data.chatMessages) { + setChatMessages(data.chatMessages); + } + + if (!firstPollDoneRef.current && !ssRestoredRef.current) { + const sp = data.players.you.savedProgress; + if (sp) { + if (sp.currentQuestion > 0) setQIdx(sp.currentQuestion); + if (sp.stage > 1) setStage(sp.stage); + } + } + firstPollDoneRef.current = true; } }) .catch(() => {}); @@ -295,9 +1085,7 @@ export default function RankedGame() { function handleNext() { const newAnswers = { ...answers, [qIdx]: selected }; setAnswers(newAnswers); - const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length; - const elapsed = Date.now() - startTime; if (qIdx < STAGE_SIZE - 1) { submitProgress(qIdx + 1, correctCount, false); @@ -305,9 +1093,10 @@ export default function RankedGame() { setQIdx(next); setSelected(newAnswers[next] ?? null); } else { - submittedRef.current = true; - submitProgress(STAGE_SIZE, correctCount, true, elapsed); - setPhase('waiting'); + const wc = STAGE_SIZE - correctCount; + setWrongCount(wc); + setResultsViewQ(0); + setPhase(phase === 'questions1' ? 'results1' : 'results2'); } } @@ -315,6 +1104,8 @@ export default function RankedGame() { const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers; setAnswers(newAnswers); const prev = qIdx - 1; + const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length; + submitProgress(prev, correctCount, false); setQIdx(prev); setSelected(newAnswers[prev] ?? null); } @@ -322,60 +1113,269 @@ export default function RankedGame() { function handleJump(targetIdx) { const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers; setAnswers(newAnswers); + const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length; + submitProgress(targetIdx, correctCount, false); setQIdx(targetIdx); setSelected(newAnswers[targetIdx] ?? null); } async function handleForfeit() { - if (!window.confirm('Are you sure you want to forfeit? This counts as a loss.')) return; + setShowForfeitConfirm(false); clearInterval(pollRef.current); + clearInterval(timerRef.current); completedRef.current = true; try { - await fetch(`${API}/game/forfeit`, { + const r = await fetch(`${API}/game/forfeit`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ gameId }), }); + if (r.ok) { + const data = await r.json(); + setEloChange(data.eloChange ?? 0); + setNewElo(data.newElo ?? null); + } } catch {} - navigate('/dashboard'); + clearGameStorage(); + setDuration(0); + setPlayerWon(false); + setPhase('complete'); } - if (phase === 'intro') - return { setPhase('question'); pollGameStatus(); }} />; + async function handleSendEmote(emote) { + setEmotePickerOpen(false); + try { + await fetch(`${API}/game/emote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ gameId, emote }), + }); + } catch {} + } - if (phase === 'question') - return ( - <> + async function handleSendQuickChat(text) { + setEmotePickerOpen(false); + try { + await fetch(`${API}/game/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ gameId, message: text }), + }); + } catch {} + } + + function dismissNotif(id) { + setWrongAnswerNotifs(prev => prev.filter(n => n.id !== id)); + } + + const showMultiplayerUI = phase === 'questions1' || phase === 'questions2' || phase === 'waiting'; + const resultsQuestion = questions[resultsViewQ] ?? questions[0]; + const resultsUserAns = answers[resultsViewQ]; + + return ( + <> + + + {phase === 'intro1' && ( +
+
+ +
+
+

+ You are ready to begin Stage 1 in Mathematics. +

+

+ Answer the next 11 questions to complete this stage. +

+
+ +
+
+
+ )} + + {(phase === 'questions1' || phase === 'questions2') && ( setShowForfeitConfirm(true)} + emoteBubble={opponentEmoteBubble} + stageNum={stage} timerDisplay={timerDisplay} retryBanner={retryBanner} + opponentStage={opponentStage} grade={grade} /> - {showDefeatOverlay && ( - { setShowDefeatOverlay(false); setPhase('complete'); }} /> - )} - - ); + )} - if (phase === 'waiting') - return ( - <> - - {showDefeatOverlay && ( - { setShowDefeatOverlay(false); setPhase('complete'); }} /> - )} - - ); + {(phase === 'results1' || phase === 'results2') && ( +
+
+

Mathematics

+
+ {wrongCount > 0 && ( +

+ {wrongCount} {wrongCount === 1 ? 'question was' : 'questions were'} incorrect. +

+ )} +
+
+
+

+ {resultsQuestion?.question} +

+
+ {(resultsQuestion?.choices ?? []).map((choice, i) => { + const sel = resultsUserAns === i; + return ( + + ); + })} +
+
+ Your answer: {resultsUserAns != null ? resultsQuestion?.choices[resultsUserAns] : '—'} +
+
+
+

+ {phase === 'results1' ? 'Stage 1' : 'Stage 2'} +

+
+ {Array.from({ length: STAGE_SIZE }, (_, i) => { + const active = i === resultsViewQ; + return ( + + ); + })} +
+
+
+
+ {wrongCount === 0 ? ( + + ) : ( + + )} +
+
+ )} - if (phase === 'complete') - return ( - navigate('/dashboard')} - /> - ); + {phase === 'intro2' && ( +
+
+ +
+
+

+ You are ready to begin Stage 2 in Mathematics. +

+

+ Answer the next 11 questions to complete this stage. +

+
+ +
+
+
+ )} - return null; + {phase === 'done' && } + + {phase === 'waiting' && ( + setShowForfeitConfirm(true)} + emoteBubble={opponentEmoteBubble} + opponentStage={opponentStage} + /> + )} + + {phase === 'complete' && ( + { clearGameStorage(); navigate('/dashboard'); }} + onPlayAgain={() => { clearGameStorage(); navigate('/queue', { state: { grade } }); }} + /> + )} + + {showDefeatOverlay && ( + { setShowDefeatOverlay(false); setPhase('complete'); }} /> + )} + {showForfeitConfirm && ( + setShowForfeitConfirm(false)} /> + )} + + {showMultiplayerUI && ( + <> + setEmotePickerOpen(v => !v)} + onSend={handleSendEmote} + onQuickChat={handleSendQuickChat} + /> + setEmotePickerOpen(false)} /> + + + )} + + ); } diff --git a/notes.txt b/notes.txt index 003e0d3..53c193c 100644 --- a/notes.txt +++ b/notes.txt @@ -1,8 +1,14 @@ -Prisma DB: npx prisma studio +Prisma DB for debugging: npx prisma studio Express server: node src/index.js Run: cd frontend && npm run dev KILL 3000: kill $(lsof -ti:3000) push: git -c http.proxy=socks5://127.0.0.1:9149 push origin main -pull: git -c http.proxy=socks5://127.0.0.1:9149 pull --rebase origin main \ No newline at end of file +pull: git -c http.proxy=socks5://127.0.0.1:9149 pull --rebase origin main + +push at school: git push origin main +pull at school: git pull --rebase origin main + +john repomix: npx repomix --ignore "node_modules,frontend/node_modules,.git,uploads,prisma/migrations,frontend/public,frontend/dist,*.svg,*.png,*.jpg,*.jpeg,repomix-output.xml,frontend/src/assets/tree_bg_svg.svg" +ssh -D 9149 -i ssh-key-2026-05-19.key ubuntu@40.233.106.58 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f70bf4d..ae9a713 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,11 @@ "license": "ISC", "dependencies": { "@prisma/client": "^5.22.0", + "bcrypt": "^6.0.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^4.22.1", + "express-rate-limit": "^8.5.2", "jsonwebtoken": "^9.0.3", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", @@ -110,6 +112,20 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -421,6 +437,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -591,6 +625,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -769,6 +812,26 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/oauth": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", diff --git a/package.json b/package.json index d556e2f..d0f2641 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,11 @@ "type": "commonjs", "dependencies": { "@prisma/client": "^5.22.0", + "bcrypt": "^6.0.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^4.22.1", + "express-rate-limit": "^8.5.2", "jsonwebtoken": "^9.0.3", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 41385b2..6ea9e04 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,14 +32,15 @@ enum SessionStatus { } model User { - id String @id @default(cuid()) - googleId String @unique - email String @unique - displayName String - username String @unique - avatarUrl String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + googleId String? @unique + email String @unique + displayName String + username String @unique + avatarUrl String? + passwordHash String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt sessions GameSession[] leaderboardEntries LeaderboardEntry[] diff --git a/src/index.js b/src/index.js index 1991a68..9733cfd 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,9 @@ const express = require('express'); const path = require('path'); const fs = require('fs'); const cors = require('cors'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const rateLimit = require('express-rate-limit'); const passport = require('./auth/google'); const authMiddleware = require('./middleware/auth'); const db = require('./db'); @@ -13,7 +16,51 @@ const { calculateElo, DEFAULT_ELO } = require('./services/elo'); const { generateStage } = require('./services/questions'); const VALID_GRADES = new Set(['G3', 'G6', 'G9']); + +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'too_many_attempts' }, + skipSuccessfulRequests: true, +}); + +const registerLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'too_many_attempts' }, +}); + +const authLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'too_many_attempts' }, +}); const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function issueJwt(user) { + return jwt.sign( + { userId: user.id, email: user.email, name: user.displayName, picture: user.avatarUrl ?? null }, + process.env.JWT_SECRET, + { algorithm: 'HS256', expiresIn: '7d' }, + ); +} + +async function generateUniqueUsernameFromEmail(email) { + const base = (email.split('@')[0].replace(/\./g, '_').replace(/[^a-zA-Z0-9_]/g, '').slice(0, 15).toLowerCase()) || 'user'; + if (!await db.user.findFirst({ where: { username: base } })) return base; + for (let i = 0; i < 20; i++) { + const candidate = `${base}_${String(Math.floor(1000 + Math.random() * 9000))}`; + if (!await db.user.findFirst({ where: { username: candidate } })) return candidate; + } + return `${base}_${Date.now()}`; +} const STAGE_SIZE = 11; const MIN_GAME_MS = 8000; // fastest a human can answer 11 questions @@ -32,6 +79,7 @@ function computeGameResult(game) { const p1 = game.players[players[1]]; if (p0.finished && p1.finished) { + if (p0.timeSpentMs === p1.timeSpentMs) return players[Math.random() < 0.5 ? 0 : 1]; return p0.timeSpentMs < p1.timeSpentMs ? players[0] : players[1]; } if (p0.finished) return players[0]; @@ -53,51 +101,65 @@ async function completeGame(game, forcedWinnerId = null) { game.winner = winnerId; game.completedAt = Date.now(); - // Snapshot both ELOs before any writes - const eloSnapshot = {}; - for (const uid of players) { - const entry = await db.leaderboardEntry.findUnique({ - where: { userId_grade: { userId: uid, grade: game.grade } }, - }); - eloSnapshot[uid] = entry?.elo ?? DEFAULT_ELO; + // No winner (e.g. both disconnected) — skip ELO, just clean up + if (!winnerId) { + for (const uid of players) delete playerGames[uid]; + game.status = 'completed'; + setTimeout(() => { delete activeGames[game.id]; }, 30000); + return; } - const loserId = players.find(id => id !== winnerId); - const { winnerChange, loserChange } = calculateElo(eloSnapshot[winnerId], eloSnapshot[loserId]); + // Snapshot both ELOs before any writes, then write atomically-as-possible + try { + const eloSnapshot = {}; + for (const uid of players) { + const entry = await db.leaderboardEntry.findUnique({ + where: { userId_grade: { userId: uid, grade: game.grade } }, + }); + eloSnapshot[uid] = entry?.elo ?? DEFAULT_ELO; + } - for (const userId of players) { - const p = game.players[userId]; - const isWinner = userId === winnerId; - const change = isWinner ? winnerChange : loserChange; - const newElo = eloSnapshot[userId] + change; + const loserId = players.find(id => id !== winnerId); + const { winnerChange, loserChange } = calculateElo(eloSnapshot[winnerId], eloSnapshot[loserId]); - await upsertLeaderboardEntry(userId, game.grade, { - elo: newElo, - bestTime: p.completedQuiz ? (p.timeSpentMs ?? undefined) : undefined, - correctAnswers: p.correctAnswers, - }); + for (const userId of players) { + const p = game.players[userId]; + const isWinner = userId === winnerId; + const change = isWinner ? winnerChange : loserChange; + const newElo = eloSnapshot[userId] + change; - p.eloChange = change; - p.newElo = newElo; + await upsertLeaderboardEntry(userId, game.grade, { + elo: newElo, + bestTime: p.completedQuiz ? (p.timeSpentMs ?? undefined) : undefined, + correctAnswers: p.correctAnswers, + }); - delete playerGames[userId]; + p.eloChange = change; + p.newElo = newElo; + + delete playerGames[userId]; + } + + // Record per-player match results for dashboard history + await Promise.all(players.map(userId => { + const p = game.players[userId]; + return db.matchResult.create({ + data: { + userId, + grade: game.grade, + won: userId === winnerId, + eloChange: p.eloChange, + eloAfter: p.newElo, + timeSpentMs: p.timeSpentMs ?? null, + }, + }); + })); + } catch (err) { + console.error('[completeGame] DB error during ELO write:', err); + // Clean up playerGames refs even on failure so players aren't stuck + for (const uid of players) delete playerGames[uid]; } - // Record per-player match results for dashboard history - await Promise.all(players.map(userId => { - const p = game.players[userId]; - return db.matchResult.create({ - data: { - userId, - grade: game.grade, - won: userId === winnerId, - eloChange: p.eloChange, - eloAfter: p.newElo, - timeSpentMs: p.timeSpentMs ?? null, - }, - }); - })); - // Set completed only after ELO is written so polls see final eloChange game.status = 'completed'; @@ -106,7 +168,7 @@ async function completeGame(game, forcedWinnerId = null) { }, 30000); } -// Cleanup stale queue entries (30s no heartbeat) and old completed games every 30s +// Cleanup stale queue entries (30s no heartbeat) and old/ghost games every 30s setInterval(() => { const now = Date.now(); for (const [userId, q] of Object.entries(queue)) { @@ -115,6 +177,18 @@ setInterval(() => { for (const [id, game] of Object.entries(activeGames)) { if (game.status === 'completed' && game.completedAt && now - game.completedAt > 60000) { delete activeGames[id]; + continue; + } + // Ghost game: all players have been silent for 60+ seconds — purge without ELO changes + if (game.status === 'active' && !game.completing) { + const allInactive = Object.values(game.players).every( + p => p.lastSeen && now - p.lastSeen > 60000, + ); + if (allInactive) { + console.log(`[cleanup] purging ghost game ${id} — all players inactive >60s`); + for (const uid of Object.keys(game.players)) delete playerGames[uid]; + delete activeGames[id]; + } } } }, 30000); @@ -129,12 +203,12 @@ const UPLOADS_DIR = path.join(__dirname, '..', 'uploads'); if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true }); app.use('/uploads', express.static(UPLOADS_DIR)); -app.get('/auth/google', passport.authenticate('google', { +app.get('/auth/google', authLimiter, passport.authenticate('google', { scope: ['profile', 'email'], session: false, })); -app.get('/auth/google/callback', (req, res, next) => { +app.get('/auth/google/callback', authLimiter, (req, res, next) => { passport.authenticate('google', { session: false }, (err, result) => { if (err || !result) { console.error('[auth/google/callback] failure:', err); @@ -144,6 +218,51 @@ app.get('/auth/google/callback', (req, res, next) => { })(req, res, next); }); +app.post('/auth/register', registerLimiter, async (req, res) => { + const { email, password, username } = req.body; + if (!email || !EMAIL_RE.test(email)) return res.status(400).json({ error: 'invalid_email' }); + if (!password || password.length < 8) return res.status(400).json({ error: 'password_too_short' }); + + let finalUsername = username ? username.trim() : null; + if (finalUsername) { + if (!USERNAME_RE.test(finalUsername)) return res.status(400).json({ error: 'invalid_username' }); + finalUsername = finalUsername.toLowerCase(); + } + + try { + if (await db.user.findUnique({ where: { email } })) return res.status(409).json({ error: 'email_taken' }); + if (!finalUsername) finalUsername = await generateUniqueUsernameFromEmail(email); + if (await db.user.findFirst({ where: { username: finalUsername } })) return res.status(409).json({ error: 'username_taken' }); + + const passwordHash = await bcrypt.hash(password, 12); + const user = await db.user.create({ + data: { email, displayName: finalUsername, username: finalUsername, passwordHash, avatarUrl: null }, + }); + res.json({ token: issueJwt(user) }); + } catch (err) { + console.error('[auth/register]', err); + if (err.code === 'P2002') { + return res.status(409).json({ error: err.meta?.target?.includes('email') ? 'email_taken' : 'username_taken' }); + } + res.status(500).json({ error: 'internal' }); + } +}); + +app.post('/auth/login', loginLimiter, async (req, res) => { + const { email, password } = req.body; + if (!email || !password) return res.status(400).json({ error: 'missing_fields' }); + try { + const user = await db.user.findUnique({ where: { email } }); + if (!user) return res.status(401).json({ error: 'invalid_credentials' }); + if (!user.passwordHash) return res.status(401).json({ error: 'use_google' }); + if (!await bcrypt.compare(password, user.passwordHash)) return res.status(401).json({ error: 'invalid_credentials' }); + res.json({ token: issueJwt(user) }); + } catch (err) { + console.error('[auth/login]', err); + res.status(500).json({ error: 'internal' }); + } +}); + app.get('/auth/me', authMiddleware, async (req, res) => { try { const user = await db.user.findUnique({ where: { id: req.user.userId } }); @@ -261,7 +380,7 @@ app.post('/game/complete', authMiddleware, async (req, res) => { }); await upsertLeaderboardEntry(userId, grade, { - bestTime: typeof timeSpentMs === 'number' ? Math.round(timeSpentMs / 1000) : undefined, + bestTime: typeof timeSpentMs === 'number' ? timeSpentMs : undefined, correctAnswers, }); @@ -319,7 +438,8 @@ app.post('/me/avatar', authMiddleware, async (req, res) => { const filepath = path.join(UPLOADS_DIR, filename); try { await fs.promises.writeFile(filepath, buf); - const avatarUrl = `${req.protocol}://${req.get('host')}/uploads/${filename}`; + const base = process.env.BACKEND_URL ?? `${req.protocol}://${req.get('host')}`; + const avatarUrl = `${base}/uploads/${filename}`; await db.user.update({ where: { id: req.user.userId }, data: { avatarUrl } }); res.json({ avatarUrl }); } catch (err) { @@ -389,6 +509,7 @@ app.post('/me/best-time', authMiddleware, async (req, res) => { where: { userId_grade: { userId: req.user.userId, grade } }, }); const isNewBest = !entry || entry.bestTime === null || timeMs < entry.bestTime; + console.log('[best-time] userId:', req.user.userId, 'grade:', grade, 'timeMs:', timeMs, 'isNewBest:', isNewBest); const updated = await db.leaderboardEntry.upsert({ where: { userId_grade: { userId: req.user.userId, grade } }, create: { userId: req.user.userId, grade, elo: 800, bestTime: timeMs, gamesPlayed: 1 }, @@ -417,6 +538,8 @@ app.post('/queue/join', authMiddleware, async (req, res) => { } if (queue[req.user.userId]) { + queue[req.user.userId].grade = grade; + queue[req.user.userId].lastSeen = Date.now(); return res.json({ status: 'waiting' }); } @@ -432,12 +555,14 @@ app.post('/queue/join', authMiddleware, async (req, res) => { const gameGrade = opponentQueue.grade; console.log(`[queue] matched ${req.user.userId} vs ${opponentId} for ${gameGrade}`); - const questions = generateStage(gameGrade, 1); + const stage1 = generateStage(gameGrade, 1); + const stage2 = generateStage(gameGrade, 2); const gameId = generateGameId(); const game = { id: gameId, grade: gameGrade, - questions, + stage1Questions: stage1, + stage2Questions: stage2, players: { [opponentId]: { displayName: opponentQueue.displayName, username: opponentQueue.username, avatarUrl: opponentQueue.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() }, [req.user.userId]: { displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() }, @@ -446,12 +571,13 @@ app.post('/queue/join', authMiddleware, async (req, res) => { winner: null, startedAt: Date.now(), completedAt: null, + chatMessages: [], }; activeGames[gameId] = game; playerGames[opponentId] = gameId; playerGames[req.user.userId] = gameId; - return res.json({ status: 'matched', gameId }); + return res.json({ status: 'matched', gameId, questions: { stage1: game.stage1Questions, stage2: game.stage2Questions } }); } queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, lastSeen: Date.now() }; @@ -480,7 +606,11 @@ app.delete('/queue/leave', authMiddleware, async (req, res) => { player.correctAnswers = 0; player.finished = true; player.finishedAt = Date.now(); - await completeGame(game, opponentId); + try { + await completeGame(game, opponentId); + } catch (err) { + console.error('[queue/leave] completeGame error:', err); + } } } } @@ -497,7 +627,7 @@ app.get('/queue/debug', authMiddleware, (_req, res) => { }); }); -app.get('/queue/status', authMiddleware, async (req, res) => { +app.get('/queue/status', authMiddleware, (req, res) => { const gameId = playerGames[req.user.userId]; if (gameId) { @@ -518,15 +648,13 @@ app.get('/queue/status', authMiddleware, async (req, res) => { return res.json({ status: 'not_in_queue' }); } - const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true, avatarUrl: true } }); - return res.json({ status: 'matched', gameId, - questions: game.questions, + questions: { stage1: game.stage1Questions, stage2: game.stage2Questions }, opponent: { - username: opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName, - avatarUrl: opponentUser?.avatarUrl ?? opponent.avatarUrl, + username: opponent.username ?? opponent.displayName, + avatarUrl: opponent.avatarUrl ?? null, }, }); } @@ -566,13 +694,17 @@ app.post('/game/forfeit', authMiddleware, async (req, res) => { game.abandonTimeout = null; const opponentId = Object.keys(game.players).find(id => id !== req.user.userId); - completeGame(game, opponentId); + try { + await completeGame(game, opponentId); + } catch (err) { + console.error('[game/forfeit] completeGame error:', err); + } - res.json({ success: true }); + res.json({ success: true, eloChange: player.eloChange ?? 0, newElo: player.newElo ?? 800 }); }); -app.post('/game/progress', authMiddleware, (req, res) => { - const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body; +app.post('/game/progress', authMiddleware, async (req, res) => { + const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs, stage } = req.body; const game = activeGames[gameId]; if (!game || game.status !== 'active') { @@ -594,31 +726,96 @@ app.post('/game/progress', authMiddleware, (req, res) => { if (correctAnswers !== undefined) { player.correctAnswers = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(correctAnswers)))); } + if (stage !== undefined) player.currentStage = Number(stage) || 1; - if (finished) { + if (finished && stage === 1) { + player.stage1Finished = true; + player.stage1CorrectCount = player.correctAnswers ?? 0; + } + + if (finished && stage === 2) { const elapsed = Date.now() - game.startedAt; const claimed = typeof timeSpentMs === 'number' ? timeSpentMs : elapsed; // Clamp: no faster than minimum human time, no slower than actual elapsed + small buffer player.timeSpentMs = Math.max(MIN_GAME_MS, Math.min(claimed, elapsed + 5000)); + player.stage2Finished = true; player.finished = true; player.finishedAt = Date.now(); player.completedQuiz = true; const winner = computeGameResult(game); if (winner) { - completeGame(game); + try { + await completeGame(game); + } catch (err) { + console.error('[game/progress] completeGame error:', err); + } } else { - // Opponent hasn't finished — award win to this player after 5 minutes if game still active + // Opponent hasn't finished — award win to this player after 2 minutes if game still active const finishedUserId = req.user.userId; - game.abandonTimeout = setTimeout(() => { - if (game.status === 'active') completeGame(game, finishedUserId); - }, 5 * 60 * 1000); + game.abandonTimeout = setTimeout(async () => { + if (game.status === 'active') { + try { + await completeGame(game, finishedUserId); + } catch (err) { + console.error('[game/progress] abandonTimeout completeGame error:', err); + } + } + }, 2 * 60 * 1000); } } res.json({ success: true }); }); +app.post('/game/emote', authMiddleware, (req, res) => { + const { gameId, emote } = req.body; + if (!gameId) return res.status(400).json({ error: 'gameId required' }); + if (!emote || typeof emote !== 'string' || !emote.trim() || emote.length > 20) { + return res.status(400).json({ error: 'invalid emote' }); + } + const game = activeGames[gameId]; + if (!game || game.status !== 'active') { + return res.status(404).json({ error: 'Game not found or already completed' }); + } + const player = game.players[req.user.userId]; + if (!player) return res.status(403).json({ error: 'Not a participant' }); + player.lastEmote = { text: emote.trim(), sentAt: Date.now() }; + res.json({ ok: true }); +}); + +app.post('/game/chat', authMiddleware, (req, res) => { + const { gameId, message } = req.body; + if (!gameId) return res.status(400).json({ error: 'gameId required' }); + if (!message || typeof message !== 'string' || !message.trim()) { + return res.status(400).json({ error: 'invalid message' }); + } + const trimmed = message.trim().slice(0, 120); + const game = activeGames[gameId]; + if (!game || game.status !== 'active') { + return res.status(404).json({ error: 'Game not found or already completed' }); + } + const player = game.players[req.user.userId]; + if (!player) return res.status(403).json({ error: 'Not a participant' }); + + if (!player.chatMsgTimes) player.chatMsgTimes = []; + const now = Date.now(); + player.chatMsgTimes = player.chatMsgTimes.filter(t => now - t < 1000); + if (player.chatMsgTimes.length >= 4) return res.status(429).json({ error: 'slow_down' }); + player.chatMsgTimes.push(now); + + game.chatMessages.push({ + userId: req.user.userId, + username: player.username ?? player.displayName, + text: trimmed, + sentAt: now, + elapsedMs: now - game.startedAt, + }); + if (game.chatMessages.length > 50) game.chatMessages.shift(); + + res.json({ ok: true }); +}); + app.get('/game/status/:gameId', authMiddleware, async (req, res) => { const game = activeGames[req.params.gameId]; if (!game) { @@ -639,11 +836,15 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => { const opponentInactive = opponent.lastSeen && Date.now() - opponent.lastSeen > 20000; if (game.status === 'active' && !player.finished && opponentInactive) { game.disconnectedPlayer = opponentId; - await completeGame(game, req.user.userId); + try { + await completeGame(game, req.user.userId); + } catch (err) { + console.error('[game/status] completeGame error:', err); + } } - const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } }); - const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName; + // Use cached name — avoids a DB query on every 1-second poll + const opponentName = opponent.username ?? opponent.displayName; const opponentDced = game.disconnectedPlayer === opponentId; const youDced = game.disconnectedPlayer === req.user.userId; @@ -664,6 +865,14 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => { timeSpentMs: player.timeSpentMs, eloChange: player.eloChange, newElo: player.newElo, + currentStage: player.currentStage ?? 1, + stage1Finished: player.stage1Finished ?? false, + stage: player.currentStage ?? 1, + savedProgress: { + currentQuestion: player.currentQuestion ?? 0, + correctAnswers: player.correctAnswers ?? 0, + stage: player.currentStage ?? 1, + }, }, opponent: { username: opponentName, @@ -671,11 +880,27 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => { correctAnswers: opponent.correctAnswers, finished: opponent.finished, timeSpentMs: opponent.timeSpentMs, + currentStage: opponent.currentStage ?? 1, + stage1Finished: opponent.stage1Finished ?? false, + lastEmote: (opponent.lastEmote && Date.now() - opponent.lastEmote.sentAt < 4000) ? opponent.lastEmote.text : null, }, }, + chatMessages: game.chatMessages ?? [], }); }); +app.get('/online-count', (_req, res) => { + const now = Date.now(); + const inQueue = Object.values(queue).filter(q => now - q.lastSeen <= 30000).length; + const inGame = Object.entries(playerGames).filter(([uid, gameId]) => { + const game = activeGames[gameId]; + if (!game || game.status !== 'active') return false; + const p = game.players[uid]; + return p && p.lastSeen && now - p.lastSeen <= 30000; + }).length; + res.json({ online: inQueue + inGame }); +}); + app.get('/health', (_req, res) => res.json({ status: 'ok' })); const PORT = process.env.PORT || 3000; diff --git a/src/services/leaderboard.js b/src/services/leaderboard.js index b1ab216..6a3ba45 100644 --- a/src/services/leaderboard.js +++ b/src/services/leaderboard.js @@ -36,11 +36,11 @@ async function getLeaderboard(grade, sortBy = 'elo', limit = 10) { orderBy, take: limit, include: { - user: { select: { displayName: true, avatarUrl: true } }, + user: { select: { displayName: true, avatarUrl: true, username: true } }, }, }); - return entries.map((entry, index) => ({ ...entry, rank: index + 1 })); + return entries.map((entry, index) => ({ ...entry, ...entry.user, rank: index + 1 })); } async function getUserRank(userId, grade, sortBy = 'elo') {