diff --git a/.gitignore b/.gitignore index ed39c62..c74ea3d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,10 @@ dist-ssr public/shot*.png test-results/ coverage/ + +# Runtime account/progress database (SQLite) +data/ +*.db +*.db-journal +*.db-wal +*.db-shm diff --git a/Dockerfile b/Dockerfile index 48f6066..94778e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,13 +18,20 @@ COPY package.json package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY server/server.mjs server/server.mjs +COPY server/db.mjs server/db.mjs +COPY shared/ shared/ COPY --from=build /app/dist dist/ +# persistent account/progress database (mount a volume here on the host) +RUN mkdir -p /app/data && chown -R node:node /app/data +VOLUME ["/app/data"] + # run as unprivileged user USER node EXPOSE 3001 ENV PORT=3001 +ENV DATA_DIR=/app/data HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD node -e "fetch('http://localhost:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" diff --git a/docker-compose.yml b/docker-compose.yml index 4f4a47c..5562058 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,5 +6,15 @@ services: ports: - "3001:3001" restart: unless-stopped + volumes: + # persistente SQLite-Datenbank (Accounts, Forschung, Statistiken) + - ./data:/app/data environment: - PORT=3001 + # --- Optional: OpenID Connect (Single Sign-On) --- + # - OIDC_ENABLED=true + # - OIDC_ISSUER=https://auth.example.com + # - OIDC_CLIENT_ID=trxtd + # - OIDC_CLIENT_SECRET=geheim + # - OIDC_REDIRECT_URI=https://trxtd.example.com/api/auth/oidc/callback + # - OIDC_BUTTON_LABEL=Mit Authelia anmelden diff --git a/package.json b/package.json index bcdfd57..cb506d3 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "server": "node server/server.mjs", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", - "sim": "tsx scripts/sim.mts" + "sim": "tsx scripts/sim.mts", + "test": "node scripts/test-db.mjs && node scripts/test-auth.mjs" }, "dependencies": { "vue": "^3.5.13", diff --git a/scripts/test-auth.mjs b/scripts/test-auth.mjs new file mode 100644 index 0000000..ec63b3a --- /dev/null +++ b/scripts/test-auth.mjs @@ -0,0 +1,322 @@ +/** + * End-to-end integration tests for the account & meta-progression REST API. + * + * Spawns the real server (server/server.mjs) against an isolated SQLite + * database in a temp dir, then exercises registration, login, sessions, + * upgrade purchases, game-finish rewards, guest migration, logout and the + * (disabled) OIDC endpoints over plain HTTP. + * + * Run: node scripts/test-auth.mjs (standalone, no server needed) + */ +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const PORT = 31000 + Math.floor(Math.random() * 500) +const BASE = `http://127.0.0.1:${PORT}` +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trxtd-auth-test-')) + +let child = null +function startServer() { + child = spawn(process.execPath, ['server/server.mjs'], { + cwd: ROOT, + env: { ...process.env, PORT: String(PORT), DATA_DIR: tmp, OIDC_ENABLED: 'false' }, + stdio: ['ignore', 'ignore', 'pipe'], + }) + child.stderr.on('data', () => {}) // drain (experimental-warning etc.) +} +startServer() + +let passed = 0 +let failed = 0 +async function check(name, fn) { + try { + await fn() + passed++ + console.log(` OK ${name}`) + } catch (e) { + failed++ + console.error(` FAIL ${name}: ${e.message}`) + } +} +const eq = (a, b, msg) => { + if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${msg || 'ungleich'}: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`) +} +const is = (cond, msg) => { + if (!cond) throw new Error(msg) +} + +async function waitForServer() { + for (let i = 0; i < 100; i++) { + try { + const r = await fetch(`${BASE}/health`) + if (r.status === 200) return + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 100)) + } + throw new Error('Server startete nicht innerhalb von 10s') +} + +// minimal cookie jar: remembers Set-Cookie, sends Cookie on later requests +function makeClient() { + const jar = new Map() + const remember = (res) => { + const sc = res.headers.get('set-cookie') + if (sc) { + const m = sc.match(/^([^=;]+)=([^;]*)/) + if (m) jar.set(m[1], m[2]) + } + } + const req = async (method, p, body) => { + const headers = {} + const cookie = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ') + if (cookie) headers.cookie = cookie + let payload + if (body !== undefined) { + headers['content-type'] = 'application/json' + payload = JSON.stringify(body) + } + const res = await fetch(`${BASE}${p}`, { method, headers, body: payload, redirect: 'manual' }) + remember(res) + let json = null + try { + json = await res.json() + } catch { + /* non-json body */ + } + return { status: res.status, json, cookieHeader: res.headers.get('set-cookie') } + } + return { req, jar } +} + +const zeroStats = { gamesPlayed: 0, gamesWon: 0, totalKills: 0, totalScore: 0, highestWave: 0 } +const anon = makeClient() + +try { + await waitForServer() + + await check('Server: /health antwortet mit 200', async () => { + const r = await fetch(`${BASE}/health`) + is(r.status === 200, `status ${r.status}`) + }) + + await check('Config: OIDC deaktiviert gemeldet', async () => { + const { status, json } = await anon.req('GET', '/api/config') + is(status === 200, `status ${status}`) + eq(json, { oidcEnabled: false, oidcLabel: 'Mit Single Sign-On anmelden' }) + }) + + await check('Auth: /me ohne Cookie -> user null', async () => { + const { status, json } = await anon.req('GET', '/api/auth/me') + eq({ status, json }, { status: 200, json: { user: null } }) + }) + + await check('Register: zu kurzer Benutzername -> 400', async () => { + const { status, json } = await anon.req('POST', '/api/auth/register', { username: 'ab', password: 'abcdefgh' }) + is(status === 400, `status ${status}`) + is(json.error.includes('Benutzername'), `error ${json.error}`) + }) + + await check('Register: zu kurzes Passwort -> 400', async () => { + const { status, json } = await anon.req('POST', '/api/auth/register', { username: 'valid_user1', password: 'kurz' }) + is(status === 400, `status ${status}`) + is(json.error.includes('Passwort'), `error ${json.error}`) + }) + + const client = makeClient() + let registerRes = null + await check('Register: Konto anlegen -> 200, Session-Cookie, leeres Profil', async () => { + registerRes = await client.req('POST', '/api/auth/register', { + username: 'TestUser', + password: 'geheim123', + displayName: 'Tester', + }) + is(registerRes.status === 200, `status ${registerRes.status}`) + const u = registerRes.json.user + is(u.username === 'testuser', `username ${u.username}`) + is(u.displayName === 'Tester', `displayName ${u.displayName}`) + is(u.crystals === 0, `crystals ${u.crystals}`) + is(u.oidc === false, 'oidc sollte false sein') + eq(u.upgrades, {}) + eq(u.stats, zeroStats) + is(/^trxtd_session=[^;]+; Path=\/; HttpOnly; SameSite=Lax/.test(registerRes.cookieHeader || ''), `cookie ${registerRes.cookieHeader}`) + }) + + await check('Auth: /me mit Session-Cookie liefert Nutzer', async () => { + const { status, json } = await client.req('GET', '/api/auth/me') + is(status === 200 && json.user?.username === 'testuser', JSON.stringify(json)) + }) + + await check('Register: doppelter Benutzername -> 409', async () => { + const { status } = await anon.req('POST', '/api/auth/register', { username: 'testuser', password: 'geheim123' }) + is(status === 409, `status ${status}`) + }) + + await check('Register: Groß-/Kleinschreibung egal -> 409', async () => { + const { status } = await anon.req('POST', '/api/auth/register', { username: 'TESTUSER', password: 'geheim123' }) + is(status === 409, `status ${status}`) + }) + + await check('Login: falsches Passwort -> 401', async () => { + const { status, json } = await anon.req('POST', '/api/auth/login', { username: 'testuser', password: 'falsch123' }) + is(status === 401, `status ${status}`) + is(json.error.includes('falsch'), `error ${json.error}`) + }) + + await check('Login: korrekt -> 200, frisches Session-Cookie', async () => { + const { status, json, cookieHeader } = await client.req('POST', '/api/auth/login', { username: 'TestUser', password: 'geheim123' }) + is(status === 200 && json.user?.username === 'testuser', JSON.stringify(json)) + is(cookieHeader && !cookieHeader.includes(registerRes.cookieHeader), 'Cookie-Token nicht erneuert') + }) + + await check('Upgrades: Kauf ohne Anmeldung -> 401', async () => { + const { status } = await anon.req('POST', '/api/upgrades/buy', { upgradeId: 'start_gold' }) + is(status === 401, `status ${status}`) + }) + + await check('Upgrades: Kauf ohne Kristalle -> 400', async () => { + const { status, json } = await client.req('POST', '/api/upgrades/buy', { upgradeId: 'start_gold' }) + is(status === 400, `status ${status}`) + is(json.error.includes('Kristalle'), `error ${json.error}`) + }) + + await check('Game: Sieg in Welle 12 -> 82 Kristalle & Statistiken', async () => { + const { status, json } = await client.req('POST', '/api/game/finish', { win: true, wave: 12, score: 3000, kills: 55 }) + is(status === 200, `status ${status}`) + is(json.crystalsEarned === 82, `crystalsEarned ${json.crystalsEarned}`) + is(json.user.crystals === 82, `crystals ${json.user.crystals}`) + eq(json.user.stats, { gamesPlayed: 1, gamesWon: 1, totalKills: 55, totalScore: 3000, highestWave: 12 }) + }) + + await check('Game: Niederlage in Welle 5 -> 14 Kristalle, Stats akkumulieren', async () => { + const { status, json } = await client.req('POST', '/api/game/finish', { win: false, wave: 5, score: 500, kills: 10 }) + is(status === 200 && json.crystalsEarned === 14, JSON.stringify(json)) + is(json.user.crystals === 96, `crystals ${json.user.crystals}`) + eq(json.user.stats, { gamesPlayed: 2, gamesWon: 1, totalKills: 65, totalScore: 3500, highestWave: 12 }) + }) + + await check('Game: negative Werte werden geklemmt (min. 1 Kristall)', async () => { + const { status, json } = await client.req('POST', '/api/game/finish', { win: false, wave: -5, score: -100, kills: -3 }) + is(status === 200 && json.crystalsEarned === 1, JSON.stringify(json)) + is(json.user.crystals === 97, `crystals ${json.user.crystals}`) + eq(json.user.stats, { gamesPlayed: 3, gamesWon: 1, totalKills: 65, totalScore: 3500, highestWave: 12 }) + }) + + await check('Upgrades: Startkapital Stufe 1 kaufen -> 67 Kristalle', async () => { + const { status, json } = await client.req('POST', '/api/upgrades/buy', { upgradeId: 'start_gold' }) + is(status === 200, `status ${status}`) + is(json.user.upgrades.start_gold === 1 && json.user.crystals === 67, JSON.stringify(json)) + }) + + await check('Upgrades: Festungsmauern Stufe 1 kaufen -> 42 Kristalle', async () => { + const { status, json } = await client.req('POST', '/api/upgrades/buy', { upgradeId: 'bonus_lives' }) + is(status === 200, `status ${status}`) + is(json.user.upgrades.bonus_lives === 1 && json.user.crystals === 42, JSON.stringify(json)) + }) + + await check('Upgrades: Stufe 2 (70) nicht bezahlbar -> 400', async () => { + const { status } = await client.req('POST', '/api/upgrades/buy', { upgradeId: 'start_gold' }) + is(status === 400, `status ${status}`) + }) + + await check('Upgrades: unbekanntes Upgrade -> 400', async () => { + const { status, json } = await client.req('POST', '/api/upgrades/buy', { upgradeId: 'hack_alles' }) + is(status === 400 && json.error.includes('Unbekannt'), JSON.stringify(json)) + }) + + await check('Merge-Guest: Kristalle & Level gemergt, Stufen gedeckelt', async () => { + const { status, json } = await client.req('POST', '/api/auth/merge-guest', { + crystals: 40, + upgrades: { start_gold: 2, dot_potency: 99, nonexistent: 7 }, + }) + is(status === 200, `status ${status}`) + is(json.user.crystals === 82, `crystals ${json.user.crystals}`) + is(json.user.upgrades.start_gold === 2, `start_gold ${json.user.upgrades.start_gold}`) + is(json.user.upgrades.dot_potency === 3, `dot_potency ${json.user.upgrades.dot_potency}`) + is(!json.user.upgrades.nonexistent, 'unbekanntes Upgrade übernommen') + }) + + await check('Upgrades: nach Merge neue Stufe kaufbar -> 42 Kristalle', async () => { + const { status, json } = await client.req('POST', '/api/upgrades/buy', { upgradeId: 'wave_bonus' }) + is(status === 200, `status ${status}`) + is(json.user.upgrades.wave_bonus === 1 && json.user.crystals === 42, JSON.stringify(json)) + }) + + await check('OIDC: Login-Endpunkt ohne Aktivierung -> 400', async () => { + const { status, json } = await anon.req('GET', '/api/auth/oidc/login') + is(status === 400 && json.error.includes('nicht aktiviert'), JSON.stringify(json)) + }) + + await check('OIDC: Callback ohne Aktivierung -> 400', async () => { + const { status } = await anon.req('GET', '/api/auth/oidc/callback?code=abc&state=xyz') + is(status === 400, `status ${status}`) + }) + + await check('Logout: Session invalidiert, /me danach null', async () => { + const out = await client.req('POST', '/api/auth/logout') + is(out.status === 200, `status ${out.status}`) + const me = await client.req('GET', '/api/auth/me') + is(me.status === 200 && me.json.user === null, JSON.stringify(me.json)) + }) + + await check('Auth: Merge-Guest ohne Anmeldung -> 401', async () => { + const { status } = await anon.req('POST', '/api/auth/merge-guest', { crystals: 1, upgrades: {} }) + is(status === 401, `status ${status}`) + }) + + await check('Game: Ergebnis ohne Anmeldung -> 401', async () => { + const { status } = await anon.req('POST', '/api/game/finish', { win: true, wave: 1, score: 10, kills: 1 }) + is(status === 401, `status ${status}`) + }) + + await check('API: unbekannter Pfad -> 404 JSON (angemeldet)', async () => { + const login = await client.req('POST', '/api/auth/login', { username: 'testuser', password: 'geheim123' }) + is(login.status === 200, `relogin ${login.status}`) + const { status, json } = await client.req('GET', '/api/gibtsnicht') + is(status === 404 && json.error, JSON.stringify({ status, json })) + }) + + if (fs.existsSync(path.join(ROOT, 'dist', 'index.html'))) { + await check('Static: / liefert index.html mit CSP-Header', async () => { + const r = await fetch(`${BASE}/`) + is(r.status === 200, `status ${r.status}`) + is((r.headers.get('content-security-policy') || '').includes("default-src 'self'"), 'CSP fehlt') + }) + } + + await check('Rate-Limit: >20 Auth-Versuche/Minute -> 429', async () => { + let seen429 = false + for (let i = 0; i < 30 && !seen429; i++) { + const { status } = await anon.req('POST', '/api/auth/register', { username: 'x', password: 'x' }) + if (status === 429) seen429 = true + } + is(seen429, 'kein 429 nach 30 Versuchen') + }) + + await check('Persistenz: Server-Neustart behält Konto & Kristalle', async () => { + // restart with the same DATA_DIR — in-memory rate-limit resets, SQLite persists + child.kill('SIGTERM') + await new Promise((r) => setTimeout(r, 400)) + startServer() + await waitForServer() + const { status, json } = await client.req('POST', '/api/auth/login', { username: 'testuser', password: 'geheim123' }) + is(status === 200, `status ${status}`) + is(json.user?.crystals === 42, `crystals ${json.user?.crystals}`) + is(json.user?.upgrades.start_gold === 2, `start_gold ${json.user?.upgrades.start_gold}`) + is(json.user?.upgrades.bonus_lives === 1, `bonus_lives ${json.user?.upgrades.bonus_lives}`) + is(json.user?.upgrades.dot_potency === 3, `dot_potency ${json.user?.upgrades.dot_potency}`) + is(json.user?.upgrades.wave_bonus === 1, `wave_bonus ${json.user?.upgrades.wave_bonus}`) + }) +} finally { + child.kill('SIGTERM') + await new Promise((r) => setTimeout(r, 300)) + fs.rmSync(tmp, { recursive: true, force: true }) +} + +console.log(`\n${passed} bestanden, ${failed} fehlgeschlagen`) +process.exit(failed === 0 ? 0 : 1) diff --git a/scripts/test-db.mjs b/scripts/test-db.mjs new file mode 100644 index 0000000..8cc6d96 --- /dev/null +++ b/scripts/test-db.mjs @@ -0,0 +1,168 @@ +/** + * Unit tests for server/db.mjs (SQLite persistence layer) and the shared + * crystal formula. Runs fully isolated in a temp DATA_DIR – no server needed. + * Run: node scripts/test-db.mjs + */ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trxtd-db-test-')) +process.env.DATA_DIR = tmp + +const db = await import('../server/db.mjs') +const { calcCrystalsEarned } = await import('../shared/meta-upgrades.mjs') + +let passed = 0 +let failed = 0 +async function check(name, fn) { + try { + await fn() + passed++ + console.log(` OK ${name}`) + } catch (e) { + failed++ + console.error(` FAIL ${name}: ${e.message}`) + } +} +const eq = (a, b, msg) => { + if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${msg || 'ungleich'}: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`) +} + +// ---------------------------------------------------------------- passwords +await check('scrypt: korrektes Passwort verifiziert', async () => { + const hash = await db.hashPassword('supersicher123') + if (await db.verifyPassword('supersicher123', hash) !== true) throw new Error('sollte true sein') +}) +await check('scrypt: falsches Passwort abgelehnt', async () => { + const hash = await db.hashPassword('supersicher123') + if (await db.verifyPassword('falschfalsch', hash) !== false) throw new Error('sollte false sein') +}) +await check('scrypt: kaputte/fehlende Hashes sind false', async () => { + if (await db.verifyPassword('x', null) !== false) throw new Error('null-Hash') + if (await db.verifyPassword('x', 'kein-doppelpunkt') !== false) throw new Error('ohne Doppelpunkt') +}) + +// ---------------------------------------------------------------- users +let alice = null +await check('createUserLocal: Nutzer mit Stats & 0 Kristallen', async () => { + alice = db.createUserLocal('Alice_01', 'Alice', await db.hashPassword('passwort123')) + if (!alice) throw new Error('kein Nutzer') + if (alice.username !== 'alice_01') throw new Error('username nicht lowercase') + if (alice.crystals !== 0) throw new Error('crystals != 0') + eq(alice.upgrades, {}, 'upgrades nicht leer') + eq(alice.stats, { gamesPlayed: 0, gamesWon: 0, totalKills: 0, totalScore: 0, highestWave: 0 }) +}) +await check('getUserByUsername: case-insensitiv, unbekannt = null', () => { + if (!db.getUserByUsername('ALICE_01')) throw new Error('sollte gefunden werden') + if (db.getUserByUsername('nobody')) throw new Error('sollte null sein') +}) +await check('createUserLocal: doppelter Username wirft', async () => { + let threw = false + try { + db.createUserLocal('alice_01', 'Alice2', await db.hashPassword('passwort123')) + } catch { + threw = true + } + if (!threw) throw new Error('UNIQUE-Constraint nicht ausgelöst') +}) + +// ---------------------------------------------------------------- sessions +let bob = null +await check('Sessions: create/get/delete Zyklus', async () => { + bob = db.createUserLocal('bob', 'Bob', await db.hashPassword('passwort123')) + const sess = db.createSession(bob.id) + const found = db.getSession(sess.token) + if (!found || found.user_id !== bob.id) throw new Error('Session nicht gefunden') + if (db.getSession('falscher-token')) throw new Error('falscher Token sollte null sein') + db.deleteSession(sess.token) + if (db.getSession(sess.token)) throw new Error('Session sollte gelöscht sein') +}) +await check('Sessions: abgelaufene Session ungültig + Cleanup', () => { + const expired = db.createSession(bob.id, -0.001) // läuft sofort ab + if (db.getSession(expired.token)) throw new Error('abgelaufene Session darf nicht gelten') + db.cleanExpiredSessions() // darf nicht werfen +}) + +// ---------------------------------------------------------------- crystals & upgrades +await check('addCrystals: summiert Kristalle', () => { + const after = db.addCrystals(bob.id, 2000) + if (after.crystals !== 2000) throw new Error(`crystals ${after.crystals}`) +}) +await check('buyUpgrade: zu wenig Kristalle abgelehnt', () => { + const poor = db.createUserLocal('poor', 'Poor', 'x'.repeat(40)) + const r = db.buyUpgrade(poor.id, 'start_gold', 30, 5) + if (r.ok) throw new Error('sollte fehlschlagen') + if (!r.error.includes('Kristalle')) throw new Error(`unerwartete Fehlermeldung: ${r.error}`) +}) +await check('buyUpgrade: 5 Stufen zu korrekten Kosten, dann Max erreicht', () => { + const costs = [30, 70, 150, 300, 600] + for (let i = 0; i < 5; i++) { + const r = db.buyUpgrade(bob.id, 'start_gold', costs[i], 5) + if (!r.ok) throw new Error(`Stufe ${i + 1} fehlgeschlagen: ${r.error}`) + if (r.user.upgrades.start_gold !== i + 1) throw new Error(`falsche Stufe ${r.user.upgrades.start_gold}`) + if (r.user.crystals !== 2000 - costs.slice(0, i + 1).reduce((a, b) => a + b, 0)) { + throw new Error(`falscher Kristallstand: ${r.user.crystals}`) + } + } + const over = db.buyUpgrade(bob.id, 'start_gold', 1, 5) + if (over.ok) throw new Error('Maximalstufe nicht erkannt') +}) + +// ---------------------------------------------------------------- guest merge +await check('mergeGuest: Kristalle gedeckelt, Levels gedeckelt, Unbekanntes ignoriert', () => { + const guest = db.createUserLocal('guest', 'Guest', 'x'.repeat(40)) + const maxLevels = { start_gold: 5, dot_potency: 3 } + const merged = db.mergeGuest(guest.id, 999999, { start_gold: 99, dot_potency: 2, nonexistent: 5 }, maxLevels) + if (merged.crystals !== 100000) throw new Error(`Kristalle nicht auf 100000 gedeckelt: ${merged.crystals}`) + if (merged.upgrades.start_gold !== 5) throw new Error(`start_gold nicht auf 5 gedeckelt: ${merged.upgrades.start_gold}`) + if (merged.upgrades.dot_potency !== 2) throw new Error(`dot_potency falsch: ${merged.upgrades.dot_potency}`) + if (merged.upgrades.nonexistent) throw new Error('unbekanntes Upgrade darf nicht übernommen werden') +}) +await check('mergeGuest: negatives/ungültiges Input wird neutralisiert', () => { + const guest = db.createUserLocal('guest2', 'Guest2', 'x'.repeat(40)) + const maxLevels = { start_gold: 5 } + const merged = db.mergeGuest(guest.id, -50, { start_gold: -3 }, maxLevels) + if (merged.crystals !== 0) throw new Error('negative Kristalle müssen 0 ergeben') + if (merged.upgrades.start_gold) throw new Error('negative Level müssen ignoriert werden') +}) +await check('mergeGuest: höherer Account-Level bleibt erhalten (MAX)', () => { + const rich = db.createUserLocal('rich', 'Rich', 'x'.repeat(40)) + db.addCrystals(rich.id, 200) + db.buyUpgrade(rich.id, 'start_gold', 30, 5) + db.buyUpgrade(rich.id, 'start_gold', 70, 5) + const merged = db.mergeGuest(rich.id, 10, { start_gold: 1 }, { start_gold: 5 }) + if (merged.upgrades.start_gold !== 2) throw new Error(`Gast-Level 1 darf Account-Level 2 nicht überschreiben: ${merged.upgrades.start_gold}`) + if (merged.crystals !== 110) throw new Error(`Kristalle: ${merged.crystals}`) +}) + +// ---------------------------------------------------------------- game results +await check('recordGameResult: Kristalle & Statistiken akkumulieren', () => { + const fresh = db.createUserLocal('stats', 'Stats', 'x'.repeat(40)) + let u = db.recordGameResult(fresh.id, { win: true, score: 3000, wave: 12, kills: 55, crystalsEarned: 82 }) + if (u.crystals !== 82) throw new Error(`crystals ${u.crystals}`) + u = db.recordGameResult(fresh.id, { win: false, score: 500, wave: 5, kills: 10, crystalsEarned: 14 }) + if (u.crystals !== 96) throw new Error(`crystals ${u.crystals}`) + eq(u.stats, { gamesPlayed: 2, gamesWon: 1, totalKills: 65, totalScore: 3500, highestWave: 12 }) +}) +await check('recordGameResult: Verlust mit crystalsEarned 0 erhöht keine Kristalle', () => { + const fresh = db.createUserLocal('noloss', 'NoLoss', 'x'.repeat(40)) + const u = db.recordGameResult(fresh.id, { win: false, score: 0, wave: 1, kills: 0, crystalsEarned: 0 }) + if (u.crystals !== 0) throw new Error(`crystals ${u.crystals}`) + if (u.stats.gamesPlayed !== 1) throw new Error('Statistik nicht hochgezählt') +}) + +// ---------------------------------------------------------------- crystal formula +await check('calcCrystalsEarned: Formel deckungsgleich mit Definition', () => { + eq(calcCrystalsEarned(12, 3000, true), 82) + eq(calcCrystalsEarned(5, 500, false), 14) + eq(calcCrystalsEarned(0, 0, false), 1, 'Minimum 1 Kristall pro Runde') + eq(calcCrystalsEarned(10, 1000, false), 29) +}) + +// ---------------------------------------------------------------- teardown +db.closeDb() +fs.rmSync(tmp, { recursive: true, force: true }) + +console.log(`\n${passed} bestanden, ${failed} fehlgeschlagen`) +process.exit(failed === 0 ? 0 : 1) diff --git a/server/db.mjs b/server/db.mjs new file mode 100644 index 0000000..4e63bb4 --- /dev/null +++ b/server/db.mjs @@ -0,0 +1,294 @@ +import { DatabaseSync } from 'node:sqlite' +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + +const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data') +if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }) +} + +const DB_PATH = path.join(DATA_DIR, 'trxtd.db') +const db = new DatabaseSync(DB_PATH) + +// Performance optimizations (WAL mode, normal synchronous) +db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + password_hash TEXT, + oidc_sub TEXT UNIQUE, + oidc_issuer TEXT, + crystals INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_login INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS user_upgrades ( + user_id TEXT NOT NULL, + upgrade_id TEXT NOT NULL, + level INTEGER NOT NULL DEFAULT 1, + unlocked_at INTEGER NOT NULL, + PRIMARY KEY(user_id, upgrade_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS user_stats ( + user_id TEXT PRIMARY KEY, + games_played INTEGER NOT NULL DEFAULT 0, + games_won INTEGER NOT NULL DEFAULT 0, + total_kills INTEGER NOT NULL DEFAULT 0, + total_score INTEGER NOT NULL DEFAULT 0, + highest_wave INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); + CREATE INDEX IF NOT EXISTS idx_users_oidc ON users(oidc_sub, oidc_issuer); +`) + +export function closeDb() { + db.close() +} + +// Helper: Password hashing using scrypt +export async function hashPassword(password) { + const salt = crypto.randomBytes(16).toString('hex') + return new Promise((resolve, reject) => { + crypto.scrypt(password, salt, 64, (err, derivedKey) => { + if (err) return reject(err) + resolve(`${salt}:${derivedKey.toString('hex')}`) + }) + }) +} + +export async function verifyPassword(password, storedHash) { + if (!storedHash || !storedHash.includes(':')) return false + const [salt, key] = storedHash.split(':') + return new Promise((resolve, reject) => { + crypto.scrypt(password, salt, 64, (err, derivedKey) => { + if (err) return reject(err) + const keyBuffer = Buffer.from(key, 'hex') + resolve(crypto.timingSafeEqual(derivedKey, keyBuffer)) + }) + }) +} + +// User operations +export function createUserLocal(username, displayName, passwordHash) { + const id = crypto.randomUUID() + const now = Date.now() + const insertUser = db.prepare(` + INSERT INTO users (id, username, display_name, password_hash, crystals, created_at, last_login) + VALUES (?, ?, ?, ?, 0, ?, ?) + `) + insertUser.run(id, username.toLowerCase(), displayName, passwordHash, now, now) + + const insertStats = db.prepare(` + INSERT INTO user_stats (user_id, games_played, games_won, total_kills, total_score, highest_wave) + VALUES (?, 0, 0, 0, 0, 0) + `) + insertStats.run(id) + + return getUserById(id) +} + +export function findOrCreateUserOidc(oidcSub, oidcIssuer, preferredUsername, displayName) { + const findStmt = db.prepare(`SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?`) + let user = findStmt.get(oidcSub, oidcIssuer) + + const now = Date.now() + if (user) { + db.prepare(`UPDATE users SET last_login = ?, display_name = ? WHERE id = ?`).run(now, displayName || user.display_name, user.id) + return getUserById(user.id) + } + + // Create unique username if taken + let cleanUsername = (preferredUsername || 'user').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 16) || 'player' + let finalUsername = cleanUsername + let counter = 1 + while (getUserByUsername(finalUsername)) { + finalUsername = `${cleanUsername}${counter++}` + } + + const id = crypto.randomUUID() + const insertUser = db.prepare(` + INSERT INTO users (id, username, display_name, oidc_sub, oidc_issuer, crystals, created_at, last_login) + VALUES (?, ?, ?, ?, ?, 0, ?, ?) + `) + insertUser.run(id, finalUsername, displayName || finalUsername, oidcSub, oidcIssuer, now, now) + + const insertStats = db.prepare(` + INSERT INTO user_stats (user_id, games_played, games_won, total_kills, total_score, highest_wave) + VALUES (?, 0, 0, 0, 0, 0) + `) + insertStats.run(id) + + return getUserById(id) +} + +export function getUserById(id) { + const user = db.prepare(`SELECT id, username, display_name, oidc_sub, crystals, created_at, last_login FROM users WHERE id = ?`).get(id) + if (!user) return null + + const upgrades = db.prepare(`SELECT upgrade_id, level FROM user_upgrades WHERE user_id = ?`).all(id) + const upgradeMap = {} + for (const u of upgrades) { + upgradeMap[u.upgrade_id] = u.level + } + + const stats = db.prepare(`SELECT * FROM user_stats WHERE user_id = ?`).get(id) || { + games_played: 0, + games_won: 0, + total_kills: 0, + total_score: 0, + highest_wave: 0, + } + + return { + ...user, + upgrades: upgradeMap, + stats: { + gamesPlayed: stats.games_played, + gamesWon: stats.games_won, + totalKills: stats.total_kills, + totalScore: stats.total_score, + highestWave: stats.highest_wave, + }, + } +} + +export function getUserByUsername(username) { + return db.prepare(`SELECT * FROM users WHERE username = ?`).get(username.toLowerCase()) +} + +// Session operations +export function createSession(userId, ttlDays = 30) { + const token = crypto.randomBytes(32).toString('hex') + const now = Date.now() + const expiresAt = now + ttlDays * 24 * 60 * 60 * 1000 + db.prepare(`INSERT INTO sessions (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`).run(token, userId, expiresAt, now) + return { token, expiresAt } +} + +export function getSession(token) { + if (!token) return null + const now = Date.now() + const row = db.prepare(` + SELECT s.token, s.user_id, s.expires_at, u.username, u.display_name + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token = ? AND s.expires_at > ? + `).get(token, now) + return row || null +} + +export function deleteSession(token) { + if (token) { + db.prepare(`DELETE FROM sessions WHERE token = ?`).run(token) + } +} + +export function cleanExpiredSessions() { + const now = Date.now() + db.prepare(`DELETE FROM sessions WHERE expires_at <= ?`).run(now) +} + +// Meta-Progression operations +export function addCrystals(userId, amount) { + db.prepare(`UPDATE users SET crystals = crystals + ? WHERE id = ?`).run(amount, userId) + return getUserById(userId) +} + +export function buyUpgrade(userId, upgradeId, cost, maxLevel = 5) { + const user = getUserById(userId) + if (!user || user.crystals < cost) return { ok: false, error: 'Nicht genügend Kristalle.' } + + const currentLevel = user.upgrades[upgradeId] || 0 + if (currentLevel >= maxLevel) return { ok: false, error: 'Upgrade bereits auf Maximalstufe.' } + + const nextLevel = currentLevel + 1 + const now = Date.now() + + db.exec('BEGIN IMMEDIATE') + try { + db.prepare(`UPDATE users SET crystals = crystals - ? WHERE id = ? AND crystals >= ?`).run(cost, userId, cost) + db.prepare(` + INSERT INTO user_upgrades (user_id, upgrade_id, level, unlocked_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, upgrade_id) DO UPDATE SET level = ? + `).run(userId, upgradeId, nextLevel, now, nextLevel) + db.exec('COMMIT') + return { ok: true, user: getUserById(userId) } + } catch (e) { + db.exec('ROLLBACK') + return { ok: false, error: e.message } + } +} + +export function mergeGuest(userId, crystals, upgrades, maxLevels) { + const cappedCrystals = Math.max(0, Math.min(100000, Math.floor(Number(crystals) || 0))) + db.exec('BEGIN IMMEDIATE') + try { + if (cappedCrystals > 0) { + db.prepare(`UPDATE users SET crystals = crystals + ? WHERE id = ?`).run(cappedCrystals, userId) + } + if (upgrades && typeof upgrades === 'object') { + for (const [upgradeId, rawLevel] of Object.entries(upgrades)) { + const max = maxLevels[upgradeId] + if (!max) continue + const level = Math.max(0, Math.min(max, Math.floor(Number(rawLevel) || 0))) + if (level <= 0) continue + const now = Date.now() + db.prepare(` + INSERT INTO user_upgrades (user_id, upgrade_id, level, unlocked_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, upgrade_id) DO UPDATE SET level = MAX(level, ?) + `).run(userId, upgradeId, level, now, level) + } + } + db.exec('COMMIT') + return getUserById(userId) + } catch (e) { + db.exec('ROLLBACK') + throw e + } +} + +export function recordGameResult(userId, { win, score, wave, kills, crystalsEarned }) { + const now = Date.now() + db.exec('BEGIN IMMEDIATE') + try { + if (crystalsEarned > 0) { + db.prepare(`UPDATE users SET crystals = crystals + ? WHERE id = ?`).run(crystalsEarned, userId) + } + db.prepare(` + UPDATE user_stats SET + games_played = games_played + 1, + games_won = games_won + ?, + total_kills = total_kills + ?, + total_score = total_score + ?, + highest_wave = MAX(highest_wave, ?) + WHERE user_id = ? + `).run(win ? 1 : 0, kills, score, wave, userId) + db.exec('COMMIT') + return getUserById(userId) + } catch (e) { + db.exec('ROLLBACK') + throw e + } +} diff --git a/server/server.mjs b/server/server.mjs index aa5625d..736ef43 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -16,8 +16,25 @@ import http from 'node:http' import { promises as fs } from 'node:fs' import path from 'node:path' +import crypto from 'node:crypto' import { fileURLToPath } from 'node:url' import { WebSocketServer } from 'ws' +import { + buyUpgrade, + cleanExpiredSessions, + createSession, + createUserLocal, + deleteSession, + findOrCreateUserOidc, + getSession, + getUserById, + getUserByUsername, + hashPassword, + mergeGuest, + recordGameResult, + verifyPassword, +} from './db.mjs' +import { META_UPGRADES, calcCrystalsEarned } from '../shared/meta-upgrades.mjs' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const DIST_DIR = process.env.DIST_DIR || path.join(__dirname, '..', 'dist') @@ -30,6 +47,31 @@ const MAX_WS_TOTAL = 500 // total concurrent websocket connections const MAX_WS_PER_IP = 20 // concurrent websocket connections per client IP const MAX_ROOMS = 300 // concurrent rooms +// ------------------------------------------------------------------ accounts & OIDC config + +const SESSION_COOKIE = 'trxtd_session' +const OIDC = { + enabled: String(process.env.OIDC_ENABLED || 'false').toLowerCase() === 'true', + issuer: String(process.env.OIDC_ISSUER || '').replace(/\/$/, ''), + clientId: String(process.env.OIDC_CLIENT_ID || ''), + clientSecret: String(process.env.OIDC_CLIENT_SECRET || ''), + redirectUri: String(process.env.OIDC_REDIRECT_URI || ''), + buttonLabel: String(process.env.OIDC_BUTTON_LABEL || 'Mit Single Sign-On anmelden'), +} +/** pending OIDC authorization attempts (state -> pkce verifier), short-lived */ +const oidcPending = new Map() +let oidcDiscovery = null +let oidcDiscoveryAt = 0 + +// periodic session & oidc-state cleanup +setInterval(() => { + cleanExpiredSessions() + const now = Date.now() + for (const [state, entry] of oidcPending.entries()) { + if (now - entry.createdAt > 10 * 60 * 1000) oidcPending.delete(state) + } +}, 10 * 60 * 1000).unref() + const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; " + @@ -88,6 +130,316 @@ function originAllowed(req) { return false } +// ------------------------------------------------------------------ auth helpers + +function parseCookies(req) { + const out = {} + const header = req.headers.cookie + if (!header) return out + for (const part of header.split(';')) { + const idx = part.indexOf('=') + if (idx === -1) continue + out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim()) + } + return out +} + +function sessionTokenFromReq(req) { + return parseCookies(req)[SESSION_COOKIE] || null +} + +function setSessionCookie(res, token, expiresAt, req) { + const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() + const secure = proto === 'https' || (req.socket?.encrypted ?? false) + const sameSite = 'Lax' + res.setHeader( + 'Set-Cookie', + `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=${sameSite}${secure ? '; Secure' : ''}; Expires=${new Date(expiresAt).toUTCString()}`, + ) +} + +function clearSessionCookie(res) { + res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`) +} + +function json(res, status, obj) { + const body = JSON.stringify(obj) + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }) + res.end(body) +} + +function readJsonBody(req, maxBytes = 8192) { + return new Promise((resolve, reject) => { + let size = 0 + const chunks = [] + req.on('data', (c) => { + size += c.length + if (size > maxBytes) { + reject(new Error('body too large')) + req.destroy() + return + } + chunks.push(c) + }) + req.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) + } catch { + reject(new Error('invalid json')) + } + }) + req.on('error', reject) + }) +} + +function publicUser(user) { + if (!user) return null + return { + id: user.id, + username: user.username, + displayName: user.display_name, + crystals: user.crystals, + upgrades: user.upgrades, + stats: user.stats, + oidc: Boolean(user.oidc_sub), + } +} + +// simple per-IP rate limit for auth endpoints +const authBuckets = new Map() +function authRateLimited(ip) { + const now = Date.now() + const b = authBuckets.get(ip) + if (!b || now > b.resetAt) { + authBuckets.set(ip, { count: 1, resetAt: now + 60000 }) + return false + } + b.count++ + return b.count > 20 // 20 auth attempts per minute per IP +} + +// ------------------------------------------------------------------ OIDC + +function base64urlDecode(s) { + return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64') +} + +async function fetchJson(url) { + const res = await fetch(url, { headers: { Accept: 'application/json' } }) + if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`) + return res.json() +} + +async function oidcDiscover() { + const now = Date.now() + if (oidcDiscovery && now - oidcDiscoveryAt < 10 * 60 * 1000) return oidcDiscovery + const d = await fetchJson(`${OIDC.issuer}/.well-known/openid-configuration`) + if (!d.authorization_endpoint || !d.token_endpoint) throw new Error('invalid OIDC discovery') + oidcDiscovery = d + oidcDiscoveryAt = now + return d +} + +async function verifyIdToken(idToken, discovery) { + const parts = idToken.split('.') + if (parts.length !== 3) throw new Error('malformed id_token') + const [h, p, sig] = parts + const header = JSON.parse(base64urlDecode(h).toString('utf8')) + const payload = JSON.parse(base64urlDecode(p).toString('utf8')) + if (header.alg !== 'RS256') throw new Error('unsupported id_token alg') + const jwks = await fetchJson(discovery.jwks_uri) + const jwk = jwks.keys.find((k) => k.kid === header.kid) || jwks.keys[0] + if (!jwk) throw new Error('no matching JWKS key') + const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' }) + const ok = crypto.verify('RSA-SHA256', Buffer.from(`${h}.${p}`), publicKey, base64urlDecode(sig)) + if (!ok) throw new Error('id_token signature invalid') + if (payload.iss !== OIDC.issuer && payload.iss !== discovery.issuer) throw new Error('issuer mismatch') + const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud] + if (!aud.includes(OIDC.clientId)) throw new Error('audience mismatch') + if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('id_token expired') + return payload +} + +// ------------------------------------------------------------------ REST API + +async function handleApi(req, res, url) { + const ip = clientIp(req) + const p = url.pathname + + // --- public config (does OIDC exist?) --- + if (p === '/api/config' && req.method === 'GET') { + return json(res, 200, { + oidcEnabled: OIDC.enabled, + oidcLabel: OIDC.buttonLabel, + }) + } + + // --- current session --- + if (p === '/api/auth/me' && req.method === 'GET') { + const sess = getSession(sessionTokenFromReq(req)) + if (!sess) return json(res, 200, { user: null }) + return json(res, 200, { user: publicUser(getUserById(sess.user_id)) }) + } + + // --- logout --- + if (p === '/api/auth/logout' && req.method === 'POST') { + deleteSession(sessionTokenFromReq(req)) + clearSessionCookie(res) + return json(res, 200, { ok: true }) + } + + // --- register (local) --- + if (p === '/api/auth/register' && req.method === 'POST') { + if (authRateLimited(ip)) return json(res, 429, { error: 'Zu viele Versuche. Bitte warte kurz.' }) + const body = await readJsonBody(req).catch(() => null) + if (!body) return json(res, 400, { error: 'Ungültige Eingabe.' }) + const username = String(body.username || '').trim().toLowerCase() + const password = String(body.password || '') + const displayName = String(body.displayName || username).trim().slice(0, 24) || username + if (!/^[a-z0-9_-]{3,16}$/.test(username)) return json(res, 400, { error: 'Benutzername: 3–16 Zeichen (a–z, 0–9, _ -).' }) + if (password.length < 8) return json(res, 400, { error: 'Passwort: mindestens 8 Zeichen.' }) + if (getUserByUsername(username)) return json(res, 409, { error: 'Benutzername bereits vergeben.' }) + const hash = await hashPassword(password) + const user = createUserLocal(username, displayName, hash) + const sess = createSession(user.id) + setSessionCookie(res, sess.token, sess.expiresAt, req) + return json(res, 200, { user: publicUser(user) }) + } + + // --- login (local) --- + if (p === '/api/auth/login' && req.method === 'POST') { + if (authRateLimited(ip)) return json(res, 429, { error: 'Zu viele Versuche. Bitte warte kurz.' }) + const body = await readJsonBody(req).catch(() => null) + if (!body) return json(res, 400, { error: 'Ungültige Eingabe.' }) + const username = String(body.username || '').trim().toLowerCase() + const password = String(body.password || '') + const row = getUserByUsername(username) + if (!row || !row.password_hash) return json(res, 401, { error: 'Benutzername oder Passwort falsch.' }) + const ok = await verifyPassword(password, row.password_hash) + if (!ok) return json(res, 401, { error: 'Benutzername oder Passwort falsch.' }) + const user = getUserById(row.id) + const sess = createSession(user.id) + setSessionCookie(res, sess.token, sess.expiresAt, req) + return json(res, 200, { user: publicUser(user) }) + } + + // --- OIDC: start login --- + if (p === '/api/auth/oidc/login' && req.method === 'GET') { + if (!OIDC.enabled) return json(res, 400, { error: 'OIDC ist nicht aktiviert.' }) + try { + const d = await oidcDiscover() + const state = crypto.randomBytes(16).toString('hex') + const verifier = crypto.randomBytes(32).toString('base64url') + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url') + oidcPending.set(state, { verifier, createdAt: Date.now() }) + const q = new URLSearchParams({ + response_type: 'code', + client_id: OIDC.clientId, + redirect_uri: OIDC.redirectUri, + scope: 'openid profile email', + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }) + res.writeHead(302, { Location: `${d.authorization_endpoint}?${q.toString()}` }) + return res.end() + } catch (e) { + return json(res, 502, { error: 'OIDC-Provider nicht erreichbar.' }) + } + } + + // --- OIDC: callback --- + if (p === '/api/auth/oidc/callback' && req.method === 'GET') { + if (!OIDC.enabled) return json(res, 400, { error: 'OIDC ist nicht aktiviert.' }) + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const pending = state ? oidcPending.get(state) : null + if (!code || !pending) { + res.writeHead(302, { Location: '/?auth=error' }) + return res.end() + } + oidcPending.delete(state) + try { + const d = await oidcDiscover() + const tokenRes = await fetch(d.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: OIDC.redirectUri, + client_id: OIDC.clientId, + client_secret: OIDC.clientSecret, + code_verifier: pending.verifier, + }).toString(), + }) + if (!tokenRes.ok) throw new Error('token exchange failed') + const tokens = await tokenRes.json() + const payload = await verifyIdToken(tokens.id_token, d) + const user = findOrCreateUserOidc( + String(payload.sub), + OIDC.issuer, + String(payload.preferred_username || payload.email || ''), + String(payload.name || payload.preferred_username || ''), + ) + const sess = createSession(user.id) + setSessionCookie(res, sess.token, sess.expiresAt, req) + res.writeHead(302, { Location: '/?auth=ok' }) + return res.end() + } catch { + res.writeHead(302, { Location: '/?auth=error' }) + return res.end() + } + } + + // --- authenticated endpoints below --- + const sess = getSession(sessionTokenFromReq(req)) + if (!sess) return json(res, 401, { error: 'Nicht angemeldet.' }) + const user = getUserById(sess.user_id) + if (!user) return json(res, 401, { error: 'Nicht angemeldet.' }) + + // --- buy meta upgrade --- + if (p === '/api/upgrades/buy' && req.method === 'POST') { + const body = await readJsonBody(req).catch(() => null) + const upgradeId = String(body?.upgradeId || '') + const def = META_UPGRADES[upgradeId] + if (!def) return json(res, 400, { error: 'Unbekanntes Upgrade.' }) + const current = user.upgrades[upgradeId] || 0 + if (current >= def.maxLevel) return json(res, 400, { error: 'Bereits auf Maximalstufe.' }) + const cost = def.costs[current] + const result = buyUpgrade(user.id, upgradeId, cost, def.maxLevel) + if (!result.ok) return json(res, 400, { error: result.error }) + return json(res, 200, { user: publicUser(result.user) }) + } + + // --- merge local guest progress into the account (one-time) --- + if (p === '/api/auth/merge-guest' && req.method === 'POST') { + const body = await readJsonBody(req).catch(() => null) + const maxLevels = {} + for (const [id, def] of Object.entries(META_UPGRADES)) maxLevels[id] = def.maxLevel + const updated = mergeGuest(user.id, body?.crystals, body?.upgrades, maxLevels) + return json(res, 200, { user: publicUser(updated) }) + } + + // --- record finished game & grant crystals (server-authoritative) --- + if (p === '/api/game/finish' && req.method === 'POST') { + const body = await readJsonBody(req).catch(() => null) + const win = Boolean(body?.win) + const wave = Math.max(0, Math.min(9999, Number(body?.wave) || 0)) + const score = Math.max(0, Math.min(10_000_000, Number(body?.score) || 0)) + const kills = Math.max(0, Math.min(1_000_000, Number(body?.kills) || 0)) + const crystals = calcCrystalsEarned(wave, score, win) + const updated = recordGameResult(user.id, { win, score, wave, kills, crystalsEarned: crystals }) + return json(res, 200, { user: publicUser(updated), crystalsEarned: crystals }) + } + + return json(res, 404, { error: 'Nicht gefunden.' }) +} + // ------------------------------------------------------------------ static files const MIME = { @@ -171,6 +523,14 @@ async function serveStatic(req, res) { } const httpServer = http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost') + if (url.pathname.startsWith('/api/')) { + handleApi(req, res, url).catch(() => { + if (!res.headersSent) res.writeHead(500) + res.end('Internal Server Error') + }) + return + } serveStatic(req, res).catch(() => { if (!res.headersSent) res.writeHead(500) res.end('Internal Server Error') diff --git a/shared/meta-upgrades.d.mts b/shared/meta-upgrades.d.mts new file mode 100644 index 0000000..5b74ad6 --- /dev/null +++ b/shared/meta-upgrades.d.mts @@ -0,0 +1,20 @@ +export interface MetaBranchData { + id: 'economy' | 'defense' | 'towers' + name: string + icon: string + desc: string +} + +export interface MetaUpgradeData { + id: string + name: string + branch: 'economy' | 'defense' | 'towers' + icon: string + desc: string + maxLevel: number + costs: number[] +} + +export declare const UPGRADE_BRANCHES: MetaBranchData[] +export declare const META_UPGRADES: Record +export declare function calcCrystalsEarned(wave: number, score: number, win: boolean): number diff --git a/shared/meta-upgrades.mjs b/shared/meta-upgrades.mjs new file mode 100644 index 0000000..238dbdf --- /dev/null +++ b/shared/meta-upgrades.mjs @@ -0,0 +1,108 @@ +/** + * Shared meta-progression definitions used by BOTH the Node server + * (authoritative validation of purchases & crystal rewards) and the + * Vue frontend (talent tree UI). Keep in sync – single source of truth. + */ + +export const UPGRADE_BRANCHES = [ + { id: 'economy', name: 'Wirtschaft', icon: '🪙', desc: 'Verbessere Startkapital, Erträge und Pionierarbeit.' }, + { id: 'defense', name: 'Verteidigung', icon: '❤️', desc: 'Stärke die Festung mit Leben, Schilden und Notfall-Pulsen.' }, + { id: 'towers', name: 'Turmforschung', icon: '⚔️', desc: 'Erhöhe Reichweite, Feuerrate und Elementarkraft aller Türme.' }, +] + +export const META_UPGRADES = { + // --- Wirtschaft --- + start_gold: { + id: 'start_gold', + name: 'Startkapital', + branch: 'economy', + icon: '💰', + desc: 'Starte jedes Spiel mit zusätzlichem Gold.', + maxLevel: 5, + costs: [30, 70, 150, 300, 600], + }, + wave_bonus: { + id: 'wave_bonus', + name: 'Golderlös', + branch: 'economy', + icon: '🪙', + desc: 'Erhöht die Belohnung für besiegte Gegner und abgeschlossene Wellen.', + maxLevel: 5, + costs: [40, 90, 200, 400, 800], + }, + obstacle_discount: { + id: 'obstacle_discount', + name: 'Pionierarbeit', + branch: 'economy', + icon: '🪓', + desc: 'Reduziert die Goldkosten zum Entfernen von Bäumen und Felsen.', + maxLevel: 3, + costs: [50, 120, 280], + }, + + // --- Verteidigung --- + bonus_lives: { + id: 'bonus_lives', + name: 'Festungsmauern', + branch: 'defense', + icon: '🛡️', + desc: 'Erhöht die maximalen Leben deiner Basis.', + maxLevel: 5, + costs: [25, 60, 140, 280, 550], + }, + shockwave: { + id: 'shockwave', + name: 'Notfall-Puls', + branch: 'defense', + icon: '💥', + desc: 'Löst bei Lebensverlust eine Schockwelle aus, die alle Gegner auf dem Weg verlangsamt.', + maxLevel: 3, + costs: [80, 200, 450], + }, + fortress_shield: { + id: 'fortress_shield', + name: 'Energieschild', + branch: 'defense', + icon: '💠', + desc: 'Absorbiert alle 5 Wellen den ersten durchbrechenden Gegner komplett ohne Lebensverlust.', + maxLevel: 1, + costs: [350], + }, + + // --- Turmforschung --- + tower_range: { + id: 'tower_range', + name: 'Weitsicht', + branch: 'towers', + icon: '🎯', + desc: 'Erhöht die Reichweite aller Türme.', + maxLevel: 5, + costs: [45, 100, 220, 450, 900], + }, + tower_speed: { + id: 'tower_speed', + name: 'Schnellfeuer', + branch: 'towers', + icon: '⚡', + desc: 'Erhöht die Angriffsgeschwindigkeit aller Türme.', + maxLevel: 5, + costs: [50, 110, 240, 500, 1000], + }, + dot_potency: { + id: 'dot_potency', + name: 'Elementarkraft', + branch: 'towers', + icon: '🔥', + desc: 'Verstärkt Gift- und Brandschaden über Zeit sowie deren Dauer.', + maxLevel: 3, + costs: [70, 180, 400], + }, +} + +/** Crystals earned for a finished run (server-authoritative, mirrored client-side). */ +export function calcCrystalsEarned(wave, score, win) { + let base = Math.floor(wave * 2.5) + if (win) base += 40 + const scoreBonus = Math.floor(score / 250) + return Math.max(1, base + scoreBonus) +} diff --git a/src/App.vue b/src/App.vue index 290adbe..133cff9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,4 +1,5 @@ + + + + diff --git a/src/components/EndOverlay.vue b/src/components/EndOverlay.vue index 4aa3985..b76ed84 100644 --- a/src/components/EndOverlay.vue +++ b/src/components/EndOverlay.vue @@ -36,6 +36,7 @@ function menu(): void {
Punkte{{ store.result.score }}
Wellen{{ store.result.wave }}
Abschüsse{{ store.result.kills }}
+
Verdient+{{ store.result.crystalsEarned }} 💎
Rekord {{ store.result.best }} @@ -116,6 +117,9 @@ h2 { .stat b { font-size: 18px; } +.stat b.crystal { + color: #7fd8ff; +} .btns { display: flex; gap: 8px; diff --git a/src/components/ResearchTree.vue b/src/components/ResearchTree.vue new file mode 100644 index 0000000..7989992 --- /dev/null +++ b/src/components/ResearchTree.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/src/components/StartScreen.vue b/src/components/StartScreen.vue index ead16fe..71d0471 100644 --- a/src/components/StartScreen.vue +++ b/src/components/StartScreen.vue @@ -3,7 +3,9 @@ import { ref } from 'vue' import { DIFFICULTIES, MAPS, MAP_ORDER } from '@/game/config' import { engine } from '@/game/engine' import { mpgame } from '@/game/mpgame' +import { applyMetaToEngine } from '@/game/auth' import { loadBest, store } from '@/game/store' +import UserProfileBar from '@/components/UserProfileBar.vue' import type { DifficultyId, MapId, MPMode } from '@/game/types' const selected = ref( @@ -24,6 +26,7 @@ function start(): void { } engine.toggleMute() engine.toggleMute() + applyMetaToEngine() engine.startGame(selected.value, selectedMap.value) } @@ -50,6 +53,7 @@ async function joinRoom(): Promise {