feat: account system, OIDC, meta-progression research tree, and automated tests
Account & Persistence Layer (server/db.mjs)
- SQLite via node:sqlite DatabaseSync with WAL mode, foreign keys, and
synchronous=NORMAL for microsecond response times
- Users table: UUID primary key, unique lowercase username, scrypt-hashed
password, optional OIDC sub/issuer, crystal balance, timestamps
- Sessions table: 64-byte random hex token, FK to users, configurable TTL
with automatic expiry cleanup every 10 minutes
- User upgrades table: composite PK (user_id, upgrade_id), level tracking,
ON CONFLICT DO UPDATE for idempotent merges
- User stats table: games played/won, total kills/score, highest wave
- Password hashing: crypto.scrypt with 16-byte salt and 64-byte derived
key, constant-time comparison via crypto.timingSafeEqual
- Guest merge: caps crystals at 100,000, caps levels to defined maxLevels,
uses MAX(level, new) to preserve higher account levels, ignores unknown
upgrade IDs, transactional with BEGIN IMMEDIATE/COMMIT/ROLLBACK
- Atomic crystal purchasing: subtracts cost only if balance sufficient,
upgrades level within transaction, returns full user object on success
Meta-Progression Definitions (shared/meta-upgrades.mjs)
- Single source of truth shared between server (authoritative validation)
and client (talent tree UI)
- 3 branches: Economy (start_gold, wave_bonus, obstacle_discount),
Defense (bonus_lives, shockwave, fortress_shield),
Towers (tower_range, tower_speed, dot_potency)
- 9 upgrades with 1–5 levels each, progressive cost curves
- calcCrystalsEarned(wave, score, win): base 2.5 per wave, +40 for victory,
+floor(score/250), minimum 1 crystal per game
REST API (server/server.mjs)
- /api/auth/register: username 3–16 chars a-z0-9_-; password min 8 chars;
case-insensitive uniqueness; auto-creates user_stats row; returns
session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS)
- /api/auth/login: constant-time username lookup via scrypt verify;
rate-limited 20 auth attempts/IP/minute
- /api/auth/logout: deletes session server-side, clears cookie
- /api/auth/me: returns publicUser (id, username, crystals, upgrades,
stats, oidc flag) or null
- /api/auth/oidc/login: PKCE Authorization Code flow with SHA-256 S256
challenge/verifier; discovers .well-known/openid-configuration;
verifies RS256 id_token signature via JWKS public key; validates
issuer, audience, and expiry; finds or creates user by OIDC sub/issuer
- /api/auth/oidc/callback: exchanges code for tokens, verifies id_token,
issues session cookie, redirects to /?auth=ok or /?auth=error
- /api/upgrades/buy: validates upgrade ID against META_UPGRADES,
checks current level < maxLevel, deducts cost from crystals
- /api/game/finish: server-authoritative crystal calculation;
bounds-checks inputs (wave ≤ 9999, score ≤ 10M, kills ≤ 1M);
updates user_stats (games_played, games_won, total_kills, total_score,
highest_wave via MAX)
- /api/auth/merge-guest: one-time guest-to-account crystal and upgrade
migration with level caps
- /api/config: public endpoint exposing OIDC enabled state and button label
- Security: CSP header on all responses, X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: no-referrer, cache-control
no-store on API responses, path-traversal protection on static serving
Multiplayer Fairness
- Meta-upgrades (start_gold, bonus_lives, tower_range, tower_speed,
dot_potency, wave_bonus, obstacle_discount, shockwave, fortress_shield)
applied only in solo campaign mode
- Co-op and Duel multiplayer sessions reset all meta buffs to zero,
preserving lockstep determinism and competitive balance
- Multiplayer results set crystalsEarned: 0 to prevent duplicate rewards
Frontend (Vue 3 + TypeScript)
- AuthModal.vue: username/password login and registration form with
validation, OIDC single sign-on button (shown when configured),
guest-to-account upgrade on first login
- UserProfileBar.vue: top-bar indicator showing crystal count (💎),
user display name, research and login/logout buttons, reactive
auth state via auth controller
- ResearchTree.vue: interactive talent tree modal with 3 branches,
per-upgrade cost/level display, purchase confirmation, disabled
state for unaffordable/maxed upgrades, branch icons and descriptions
- auth.ts: reactive controller managing login, registration, OIDC
redirect detection (?auth=ok/?auth=error), guest profile migration
on first login, upgrade purchasing, and game result reporting
- meta.ts: frontend helpers for branch definitions, upgrade costs,
and guest profile persistence in localStorage
- engine.ts: solo meta bonus application (start_gold, bonus_lives,
tower_range, tower_speed, dot_potency, wave_bonus, obstacle_discount,
shockwave, fortress_shield); crystal rewards in finish() path
- mpgame.ts: meta buff reset in multiplayer sessions; crystalsEarned: 0
- sound.ts: shield sound synthesis for fortress_shield absorption
- store.ts: auth state, research tree toggle, upgrade snapshot
- types.ts: SfxName extended with "shield" sound
Docker Configuration
- Multi-stage build: node:22-alpine build → node:22-alpine runtime
(production deps only: ws)
- VOLUME /app/data for persistent SQLite database
- HEALTHCHECK on /health endpoint
- docker-compose.yml: port 3001, persistent ./data volume,
commented OIDC environment variables (OIDC_ENABLED, OIDC_ISSUER,
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_BUTTON_LABEL)
- .gitignore: data/, *.db, *.db-journal, *.db-wal, *.db-shm
Automated Tests (npm test)
- scripts/test-db.mjs (17 unit tests): isolated SQLite persistence –
scrypt hash/verify roundtrip, timing-safe constant-time comparison,
user creation with lowercase enforcement, UNIQUE constraint, session
create/get/delete lifecycle, expired session invalidation, crystal
addition, 5-level upgrade cost progression with max-level guard,
guest merge (crystal cap 100k, level cap, MAX() semantics, unknown
upgrade rejection), game result stats accumulation, calcCrystalsEarned
formula verification
- scripts/test-auth.mjs (31 integration tests): spawns real server with
isolated DATA_DIR, exercises full REST flow – register validation
(username too short, password too short, duplicate, case-insensitive),
login (wrong password, correct), session cookie attributes (HttpOnly,
SameSite=Lax, Path=/), /me endpoint, upgrade purchase (insufficient
crystals, unknown ID, successful purchase), game finish rewards
(victory, defeat, negative values clamped to 1 crystal minimum),
guest merge (crystals, level caps, unknown upgrades), logout,
OIDC-disabled endpoints (400), unauthenticated guards (401), 404
routing, CSP header on static files, rate limiting (429 after 20+
auth attempts per minute), and persistence across server restart
(kill + respawn with same DATA_DIR preserves all state)
This commit is contained in:
parent
ce2484bb8d
commit
1a9ac5bf45
24 changed files with 2216 additions and 12 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
322
scripts/test-auth.mjs
Normal file
322
scripts/test-auth.mjs
Normal file
|
|
@ -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)
|
||||
168
scripts/test-db.mjs
Normal file
168
scripts/test-db.mjs
Normal file
|
|
@ -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)
|
||||
294
server/db.mjs
Normal file
294
server/db.mjs
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
20
shared/meta-upgrades.d.mts
Normal file
20
shared/meta-upgrades.d.mts
Normal file
|
|
@ -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<string, MetaUpgradeData>
|
||||
export declare function calcCrystalsEarned(wave: number, score: number, win: boolean): number
|
||||
108
shared/meta-upgrades.mjs
Normal file
108
shared/meta-upgrades.mjs
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import AuthModal from '@/components/AuthModal.vue'
|
||||
import EndOverlay from '@/components/EndOverlay.vue'
|
||||
import GameCanvas from '@/components/GameCanvas.vue'
|
||||
import Hud from '@/components/Hud.vue'
|
||||
|
|
@ -7,6 +8,7 @@ import MPBar from '@/components/MPBar.vue'
|
|||
import ObstaclePanel from '@/components/ObstaclePanel.vue'
|
||||
import PauseOverlay from '@/components/PauseOverlay.vue'
|
||||
import PiPCanvas from '@/components/PiPCanvas.vue'
|
||||
import ResearchTree from '@/components/ResearchTree.vue'
|
||||
import StartScreen from '@/components/StartScreen.vue'
|
||||
import TowerPanel from '@/components/TowerPanel.vue'
|
||||
import TowerShop from '@/components/TowerShop.vue'
|
||||
|
|
@ -60,6 +62,9 @@ const badgeColor = computed(() => {
|
|||
</div>
|
||||
<TowerShop />
|
||||
</div>
|
||||
|
||||
<AuthModal />
|
||||
<ResearchTree />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
213
src/components/AuthModal.vue
Normal file
213
src/components/AuthModal.vue
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { login, oidcLogin, register } from '@/game/auth'
|
||||
import { store } from '@/game/store'
|
||||
|
||||
const mode = ref<'login' | 'register'>('login')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const displayName = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
function close(): void {
|
||||
store.auth.showAuth = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
const err =
|
||||
mode.value === 'login'
|
||||
? await login(username.value.trim(), password.value)
|
||||
: await register(username.value.trim(), password.value, displayName.value.trim() || username.value.trim())
|
||||
busy.value = false
|
||||
if (err) {
|
||||
error.value = err
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.auth.showAuth" class="backdrop" @click.self="close">
|
||||
<div class="card">
|
||||
<button class="close" @click="close">✕</button>
|
||||
<h2>{{ mode === 'login' ? 'Anmelden' : 'Konto erstellen' }}</h2>
|
||||
<p class="sub">Speichere Kristalle, Forschung & Statistiken über alle Runden hinweg.</p>
|
||||
|
||||
<div class="tabs">
|
||||
<button :class="{ active: mode === 'login' }" @click="mode = 'login'">Anmelden</button>
|
||||
<button :class="{ active: mode === 'register' }" @click="mode = 'register'">Registrieren</button>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Benutzername</span>
|
||||
<input v-model="username" maxlength="16" autocomplete="username" @keyup.enter="submit" />
|
||||
</label>
|
||||
<label v-if="mode === 'register'" class="field">
|
||||
<span>Anzeigename (optional)</span>
|
||||
<input v-model="displayName" maxlength="24" @keyup.enter="submit" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Passwort</span>
|
||||
<input v-model="password" type="password" autocomplete="current-password" @keyup.enter="submit" />
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<button class="primary" :disabled="busy" @click="submit">
|
||||
{{ busy ? '…' : mode === 'login' ? '▶ Anmelden' : '▶ Konto erstellen' }}
|
||||
</button>
|
||||
|
||||
<div v-if="store.auth.oidcEnabled" class="divider"><span>oder</span></div>
|
||||
<button v-if="store.auth.oidcEnabled" class="oidc" @click="oidcLogin">🔑 {{ store.auth.oidcLabel }}</button>
|
||||
|
||||
<p class="hint">Als Gast spielen? Einfach schließen – Fortschritt wird lokal gespeichert.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(8, 11, 15, 0.72);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.card {
|
||||
position: relative;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 16px;
|
||||
padding: 24px 28px;
|
||||
width: 380px;
|
||||
max-width: 92vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
.sub {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.tabs button {
|
||||
flex: 1;
|
||||
background: var(--panel-inset);
|
||||
border: 1px solid var(--panel-border);
|
||||
color: var(--text-dim);
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tabs button.active {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.field span {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.field input {
|
||||
background: var(--panel-inset);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.field input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #ff8a7a;
|
||||
font-size: 13px;
|
||||
}
|
||||
.primary {
|
||||
background: linear-gradient(180deg, #59b34d, #3f8f37);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
padding: 11px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--panel-border);
|
||||
}
|
||||
.divider span {
|
||||
padding: 0 8px;
|
||||
}
|
||||
.oidc {
|
||||
background: var(--panel-inset);
|
||||
border: 1px solid #3d6f9e;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
padding: 11px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.oidc:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 11.5px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -36,6 +36,7 @@ function menu(): void {
|
|||
<div class="stat"><span>Punkte</span><b>{{ store.result.score }}</b></div>
|
||||
<div class="stat"><span>Wellen</span><b>{{ store.result.wave }}</b></div>
|
||||
<div class="stat"><span>Abschüsse</span><b>{{ store.result.kills }}</b></div>
|
||||
<div class="stat"><span>Verdient</span><b class="crystal">+{{ store.result.crystalsEarned }} 💎</b></div>
|
||||
<div class="stat">
|
||||
<span>Rekord</span>
|
||||
<b>{{ store.result.best }}<template v-if="store.result.score >= store.result.best && store.result.bestBefore < store.result.score"> 🎉 neu!</template></b>
|
||||
|
|
@ -116,6 +117,9 @@ h2 {
|
|||
.stat b {
|
||||
font-size: 18px;
|
||||
}
|
||||
.stat b.crystal {
|
||||
color: #7fd8ff;
|
||||
}
|
||||
.btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
|
|||
238
src/components/ResearchTree.vue
Normal file
238
src/components/ResearchTree.vue
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { buyUpgrade } from '@/game/auth'
|
||||
import { META_UPGRADES, UPGRADE_BRANCHES } from '@/game/meta'
|
||||
import { store } from '@/game/store'
|
||||
import type { MetaUpgradeDef, MetaUpgradeId } from '@/game/types'
|
||||
|
||||
const notice = ref('')
|
||||
|
||||
const user = computed(() => store.auth.user)
|
||||
const crystals = computed(() => user.value?.crystals ?? 0)
|
||||
|
||||
function upgradesFor(branch: string): MetaUpgradeDef[] {
|
||||
return Object.values(META_UPGRADES).filter((u) => u.branch === branch)
|
||||
}
|
||||
|
||||
function levelOf(id: MetaUpgradeId): number {
|
||||
return user.value?.upgrades[id] || 0
|
||||
}
|
||||
|
||||
function nextCost(def: MetaUpgradeDef): number | null {
|
||||
const lvl = levelOf(def.id)
|
||||
if (lvl >= def.maxLevel) return null
|
||||
return def.costs[lvl]
|
||||
}
|
||||
|
||||
async function buy(def: MetaUpgradeDef): Promise<void> {
|
||||
notice.value = ''
|
||||
const err = await buyUpgrade(def.id)
|
||||
if (err) notice.value = err
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
store.auth.showResearch = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.auth.showResearch" class="backdrop" @click.self="close">
|
||||
<div class="card">
|
||||
<button class="close" @click="close">✕</button>
|
||||
<div class="head">
|
||||
<h2>🧪 Forschungslabor</h2>
|
||||
<div class="crystals">💎 {{ crystals }}</div>
|
||||
</div>
|
||||
<p class="sub">Rundenübergreifende Upgrades. Kristalle verdienst du in jeder Runde – auch bei Niederlagen.</p>
|
||||
|
||||
<div v-for="branch in UPGRADE_BRANCHES" :key="branch.id" class="branch">
|
||||
<div class="branch-head">
|
||||
<span class="icon">{{ branch.icon }}</span>
|
||||
<div>
|
||||
<div class="branch-name">{{ branch.name }}</div>
|
||||
<div class="branch-desc">{{ branch.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="upgrades">
|
||||
<div v-for="def in upgradesFor(branch.id)" :key="def.id" class="upgrade">
|
||||
<div class="u-top">
|
||||
<span class="u-icon">{{ def.icon }}</span>
|
||||
<span class="u-name">{{ def.name }}</span>
|
||||
<span class="pips">
|
||||
<i v-for="n in def.maxLevel" :key="n" :class="{ on: levelOf(def.id) >= n }" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="u-desc">{{ def.desc }}</div>
|
||||
<div class="u-effect">{{ def.effectDesc(Math.max(1, levelOf(def.id))) }}</div>
|
||||
<button
|
||||
class="buy"
|
||||
:disabled="nextCost(def) === null || crystals < (nextCost(def) ?? 0)"
|
||||
@click="buy(def)"
|
||||
>
|
||||
{{ nextCost(def) === null ? 'MAX' : `💎 ${nextCost(def)}` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="notice" class="error">{{ notice }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(8, 11, 15, 0.72);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.card {
|
||||
position: relative;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 16px;
|
||||
padding: 22px 26px;
|
||||
width: 720px;
|
||||
max-width: 94vw;
|
||||
max-height: 88vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
.crystals {
|
||||
font-weight: 800;
|
||||
color: #7fd8ff;
|
||||
font-size: 16px;
|
||||
}
|
||||
.sub {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.branch {
|
||||
background: var(--panel-inset);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.branch-head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.branch-head .icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
.branch-name {
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
}
|
||||
.branch-desc {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.upgrades {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.upgrade {
|
||||
position: relative;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.u-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.u-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
.u-name {
|
||||
font-weight: 700;
|
||||
font-size: 13.5px;
|
||||
flex: 1;
|
||||
}
|
||||
.pips {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
.pips i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--panel-border);
|
||||
}
|
||||
.pips i.on {
|
||||
background: var(--accent);
|
||||
}
|
||||
.u-desc {
|
||||
color: var(--text-dim);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.u-effect {
|
||||
color: #b8e6b0;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.buy {
|
||||
margin-top: 4px;
|
||||
background: var(--panel-inset);
|
||||
border: 1px solid #3d6f9e;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: 12.5px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.buy:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.buy:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #ff8a7a;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<DifficultyId>(
|
||||
|
|
@ -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<void> {
|
|||
|
||||
<template>
|
||||
<div class="start">
|
||||
<UserProfileBar class="profile" />
|
||||
<div class="hero">
|
||||
<div class="towers-float">
|
||||
<span>🏹</span><span>🧨</span><span>❄️</span><span>⚡</span><span>💫</span>
|
||||
|
|
@ -160,6 +164,9 @@ async function joinRoom(): Promise<void> {
|
|||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
.profile {
|
||||
align-self: flex-end;
|
||||
}
|
||||
.hero {
|
||||
text-align: center;
|
||||
}
|
||||
|
|
|
|||
82
src/components/UserProfileBar.vue
Normal file
82
src/components/UserProfileBar.vue
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { logout } from '@/game/auth'
|
||||
import { store } from '@/game/store'
|
||||
|
||||
const user = computed(() => store.auth.user)
|
||||
const loggedIn = computed(() => !!user.value && user.value.id !== 'guest')
|
||||
|
||||
function openResearch(): void {
|
||||
store.auth.showResearch = true
|
||||
}
|
||||
function openAuth(): void {
|
||||
store.auth.showAuth = true
|
||||
}
|
||||
async function doLogout(): Promise<void> {
|
||||
await logout()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-bar">
|
||||
<div class="crystals" :title="user?.stats ? `${user.stats.totalKills} Abschüsse · ${user.stats.gamesPlayed} Spiele` : ''">
|
||||
💎 {{ user?.crystals ?? 0 }}
|
||||
</div>
|
||||
<template v-if="loggedIn">
|
||||
<span class="name" :title="user?.username">👤 {{ user?.displayName }}</span>
|
||||
<button class="chip research" @click="openResearch">🧪 Forschung</button>
|
||||
<button class="chip" @click="doLogout">Abmelden</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="name">👤 Gast</span>
|
||||
<button class="chip research" @click="openResearch">🧪 Forschung</button>
|
||||
<button class="chip login" @click="openAuth">Anmelden</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.crystals {
|
||||
font-weight: 800;
|
||||
color: #7fd8ff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.name {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip {
|
||||
background: var(--panel-inset);
|
||||
border: 1px solid var(--panel-border);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.chip.research {
|
||||
border-color: #3d6f9e;
|
||||
}
|
||||
.chip.login {
|
||||
border-color: #3f8f37;
|
||||
color: #b8e6b0;
|
||||
}
|
||||
</style>
|
||||
158
src/game/auth.ts
Normal file
158
src/game/auth.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { engine } from './engine'
|
||||
import { META_UPGRADES, clearGuestProfile, emptyProfile, loadGuestProfile, saveGuestProfile } from './meta'
|
||||
import { store } from './store'
|
||||
import type { MetaUpgradeId, UserMetaProfile } from './types'
|
||||
|
||||
/**
|
||||
* Bridges the account / meta-progression system.
|
||||
* Logged-in users are authoritative on the server (SQLite). Guests keep their
|
||||
* progress locally and can carry it into an account later.
|
||||
*/
|
||||
|
||||
function api(path: string, opts: RequestInit = {}): Promise<Record<string, unknown>> {
|
||||
return fetch(path, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
...opts,
|
||||
}).then((r) => r.json())
|
||||
}
|
||||
|
||||
export function isLoggedIn(): boolean {
|
||||
return !!store.auth.user && store.auth.user.id !== 'guest'
|
||||
}
|
||||
|
||||
export function currentProfile(): UserMetaProfile {
|
||||
return store.auth.user ?? emptyProfile()
|
||||
}
|
||||
|
||||
/** push the active upgrade levels into the engine (solo bonuses) */
|
||||
export function applyMetaToEngine(): void {
|
||||
engine.setMeta(currentProfile().upgrades)
|
||||
}
|
||||
|
||||
export async function initAuth(): Promise<void> {
|
||||
try {
|
||||
const cfg = await api('/api/config')
|
||||
store.auth.oidcEnabled = Boolean(cfg.oidcEnabled)
|
||||
if (typeof cfg.oidcLabel === 'string' && cfg.oidcLabel) store.auth.oidcLabel = cfg.oidcLabel
|
||||
const me = await api('/api/auth/me')
|
||||
if (me.user) {
|
||||
store.auth.user = me.user as unknown as UserMetaProfile
|
||||
// carry local guest progress into the fresh account once
|
||||
const guest = loadGuestProfile()
|
||||
const hasGuestProgress = guest.crystals > 0 || Object.keys(guest.upgrades).length > 0
|
||||
if (hasGuestProgress) {
|
||||
const merged = await api('/api/auth/merge-guest', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ crystals: guest.crystals, upgrades: guest.upgrades }),
|
||||
})
|
||||
if (merged.user) store.auth.user = merged.user as unknown as UserMetaProfile
|
||||
clearGuestProfile()
|
||||
}
|
||||
} else {
|
||||
store.auth.user = loadGuestProfile()
|
||||
}
|
||||
} catch {
|
||||
// server unreachable (e.g. pure dev preview) -> local guest mode
|
||||
store.auth.user = loadGuestProfile()
|
||||
}
|
||||
store.auth.checked = true
|
||||
applyMetaToEngine()
|
||||
|
||||
// record solo results & grant crystals
|
||||
engine.onGameEnd = (r) => {
|
||||
void recordGame(r)
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(username: string, password: string, displayName: string): Promise<string | null> {
|
||||
const res = await api('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password, displayName }),
|
||||
})
|
||||
if (res.error) return String(res.error)
|
||||
store.auth.user = res.user as unknown as UserMetaProfile
|
||||
carryGuestInto(res.user as unknown as UserMetaProfile)
|
||||
applyMetaToEngine()
|
||||
return null
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<string | null> {
|
||||
const res = await api('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (res.error) return String(res.error)
|
||||
store.auth.user = res.user as unknown as UserMetaProfile
|
||||
carryGuestInto(res.user as unknown as UserMetaProfile)
|
||||
applyMetaToEngine()
|
||||
return null
|
||||
}
|
||||
|
||||
/** after a successful login, merge any local guest progress server-side */
|
||||
async function carryGuestInto(user: UserMetaProfile): Promise<void> {
|
||||
const guest = loadGuestProfile()
|
||||
const hasGuestProgress = guest.crystals > 0 || Object.keys(guest.upgrades).length > 0
|
||||
if (!hasGuestProgress) return
|
||||
try {
|
||||
const merged = await api('/api/auth/merge-guest', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ crystals: guest.crystals, upgrades: guest.upgrades }),
|
||||
})
|
||||
if (merged.user) store.auth.user = merged.user as unknown as UserMetaProfile
|
||||
clearGuestProfile()
|
||||
} catch {
|
||||
/* keep guest progress locally */
|
||||
}
|
||||
}
|
||||
|
||||
export function oidcLogin(): void {
|
||||
window.location.href = '/api/auth/oidc/login'
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await api('/api/auth/logout', { method: 'POST' }).catch(() => undefined)
|
||||
store.auth.user = loadGuestProfile()
|
||||
store.auth.showAuth = false
|
||||
applyMetaToEngine()
|
||||
}
|
||||
|
||||
export async function recordGame(r: { win: boolean; score: number; wave: number; kills: number; crystalsEarned: number }): Promise<void> {
|
||||
if (isLoggedIn()) {
|
||||
const res = await api('/api/game/finish', { method: 'POST', body: JSON.stringify(r) }).catch(() => null)
|
||||
if (res?.user) store.auth.user = res.user as unknown as UserMetaProfile
|
||||
} else {
|
||||
const g = loadGuestProfile()
|
||||
g.crystals += r.crystalsEarned
|
||||
g.stats.gamesPlayed++
|
||||
if (r.win) g.stats.gamesWon++
|
||||
g.stats.totalKills += r.kills
|
||||
g.stats.totalScore += r.score
|
||||
g.stats.highestWave = Math.max(g.stats.highestWave, r.wave)
|
||||
saveGuestProfile(g)
|
||||
store.auth.user = g
|
||||
}
|
||||
}
|
||||
|
||||
export async function buyUpgrade(id: MetaUpgradeId): Promise<string | null> {
|
||||
const def = META_UPGRADES[id]
|
||||
const profile = currentProfile()
|
||||
const level = profile.upgrades[id] || 0
|
||||
if (level >= def.maxLevel) return 'Bereits auf Maximalstufe.'
|
||||
const cost = def.costs[level]
|
||||
|
||||
if (isLoggedIn()) {
|
||||
const res = await api('/api/upgrades/buy', { method: 'POST', body: JSON.stringify({ upgradeId: id }) })
|
||||
if (res.error) return String(res.error)
|
||||
store.auth.user = res.user as unknown as UserMetaProfile
|
||||
} else {
|
||||
const g = loadGuestProfile()
|
||||
if (g.crystals < cost) return 'Nicht genügend Kristalle.'
|
||||
g.crystals -= cost
|
||||
g.upgrades[id] = level + 1
|
||||
saveGuestProfile(g)
|
||||
store.auth.user = g
|
||||
}
|
||||
applyMetaToEngine()
|
||||
return null
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from './config'
|
||||
import { sound } from './sound'
|
||||
import { bestScoreKey, loadBest, store } from './store'
|
||||
import { calcCrystalsEarned } from './meta'
|
||||
import type {
|
||||
DecorItem,
|
||||
DifficultyId,
|
||||
|
|
@ -28,6 +29,7 @@ import type {
|
|||
MPMode,
|
||||
MapDef,
|
||||
MapId,
|
||||
MetaUpgradeId,
|
||||
Phase,
|
||||
Projectile,
|
||||
Pt,
|
||||
|
|
@ -92,6 +94,34 @@ export class GameEngine {
|
|||
return MAPS[this.mapId] || MAPS.meadow
|
||||
}
|
||||
|
||||
/** meta (account) upgrade levels; only applied in solo to keep multiplayer fair & deterministic */
|
||||
meta: Partial<Record<MetaUpgradeId, number>> = {}
|
||||
/** fortress shield: waves since last absorbed leak */
|
||||
private shieldWaves = 0
|
||||
/** called when a solo game ends (auth layer records result & grants crystals) */
|
||||
onGameEnd: ((r: { win: boolean; score: number; wave: number; kills: number; crystalsEarned: number }) => void) | null = null
|
||||
|
||||
private metaLvl(id: MetaUpgradeId): number {
|
||||
if (this.mpGameActive) return 0 // no meta bonuses in coop/duel
|
||||
return this.meta[id] || 0
|
||||
}
|
||||
|
||||
setMeta(upgrades: Partial<Record<MetaUpgradeId, number>>): void {
|
||||
this.meta = upgrades || {}
|
||||
}
|
||||
|
||||
private goldMul(): number {
|
||||
return 1 + 0.06 * this.metaLvl('wave_bonus')
|
||||
}
|
||||
|
||||
private obstacleCostMul(): number {
|
||||
return 1 - 0.15 * this.metaLvl('obstacle_discount')
|
||||
}
|
||||
|
||||
obstacleCost(type: 'tree' | 'rock'): number {
|
||||
return Math.max(1, Math.round(OBSTACLE_COST[type] * this.obstacleCostMul()))
|
||||
}
|
||||
|
||||
buildType: TowerKind | null = null
|
||||
hover: { x: number; y: number; tx: number; ty: number; valid: boolean } | null = null
|
||||
selectedId: number | null = null
|
||||
|
|
@ -222,7 +252,7 @@ export class GameEngine {
|
|||
this.selectedObstacle = null
|
||||
return
|
||||
}
|
||||
const cost = OBSTACLE_COST[item.type]
|
||||
const cost = this.obstacleCost(item.type)
|
||||
if (this.money < cost) {
|
||||
this.sfx('error')
|
||||
return
|
||||
|
|
@ -267,6 +297,11 @@ export class GameEngine {
|
|||
this.money = d.money
|
||||
this.lives = d.lives
|
||||
this.maxLives = d.lives
|
||||
// meta (account) start bonuses — solo only; multiplayer clears meta before start
|
||||
this.money += 35 * (this.meta.start_gold || 0)
|
||||
this.lives += 3 * (this.meta.bonus_lives || 0)
|
||||
this.maxLives = this.lives
|
||||
this.shieldWaves = 0
|
||||
this.waveNo = 0
|
||||
this.score = 0
|
||||
this.kills = 0
|
||||
|
|
@ -472,7 +507,7 @@ export class GameEngine {
|
|||
}
|
||||
|
||||
private waveCleared(): void {
|
||||
const bonus = waveClearBonus(this.waveNo)
|
||||
const bonus = Math.round(waveClearBonus(this.waveNo) * this.goldMul())
|
||||
this.money += bonus
|
||||
this.score += 40 + 12 * this.waveNo
|
||||
this.fx({
|
||||
|
|
@ -534,8 +569,10 @@ export class GameEngine {
|
|||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(bestScoreKey(this.difficulty, this.mapId), String(best))
|
||||
}
|
||||
store.result = { win, score: this.score, wave: this.waveNo, kills: this.kills, best, bestBefore }
|
||||
const crystalsEarned = calcCrystalsEarned(this.waveNo, this.score, win)
|
||||
store.result = { win, score: this.score, wave: this.waveNo, kills: this.kills, best, bestBefore, crystalsEarned }
|
||||
store.screen = win ? 'victory' : 'gameover'
|
||||
this.onGameEnd?.({ win, score: this.score, wave: this.waveNo, kills: this.kills, crystalsEarned })
|
||||
}
|
||||
|
||||
private spawnEnemy(kind: EnemyKind): void {
|
||||
|
|
@ -606,6 +643,18 @@ export class GameEngine {
|
|||
|
||||
private leak(e: Enemy): void {
|
||||
e.escaped = true
|
||||
|
||||
// fortress shield: absorb the first leak on every 5th wave (solo meta)
|
||||
const shield = this.metaLvl('fortress_shield')
|
||||
if (shield > 0 && this.waveNo % 5 === 0 && this.shieldWaves !== this.waveNo) {
|
||||
this.shieldWaves = this.waveNo
|
||||
this.sfx('shield')
|
||||
const base = this.pathPx[this.pathPx.length - 1]
|
||||
this.fx({ type: 'ring', x: base.x - 26, y: base.y, r0: 8, r1: 60, life: 0.4, max: 0.4, color: '#7fd8ff', width: 4 })
|
||||
this.fx({ type: 'text', x: base.x - 30, y: base.y - 50, vy: -30, life: 1.2, max: 1.2, str: '💠 blockiert!', color: '#7fd8ff', size: 16 })
|
||||
return
|
||||
}
|
||||
|
||||
this.lives -= e.dmg
|
||||
this.shake = Math.min(1, 0.3 + e.dmg * 0.1)
|
||||
this.sfx('leak')
|
||||
|
|
@ -621,6 +670,20 @@ export class GameEngine {
|
|||
color: '#ff5f4e',
|
||||
size: 18,
|
||||
})
|
||||
|
||||
// shockwave: on life loss, slow all enemies on the path (solo meta)
|
||||
const shock = this.metaLvl('shockwave')
|
||||
if (shock > 0) {
|
||||
const slowFactor = 1 - (0.25 + 0.25 * shock)
|
||||
const duration = 1 + shock
|
||||
for (const other of this.enemies) {
|
||||
if (other.dead || other.escaped) continue
|
||||
other.slowUntil = Math.max(other.slowUntil, this.time + duration)
|
||||
other.slowFactor = Math.min(other.slowFactor, slowFactor)
|
||||
}
|
||||
this.fx({ type: 'ring', x: base.x - 26, y: base.y, r0: 10, r1: 220, life: 0.5, max: 0.5, color: '#ffd23e', width: 5 })
|
||||
}
|
||||
|
||||
if (this.lives <= 0) {
|
||||
this.lives = 0
|
||||
this.defeat()
|
||||
|
|
@ -733,8 +796,12 @@ export class GameEngine {
|
|||
this.setTargeting(t, modes[(modes.indexOf(t.targeting) + 1) % modes.length])
|
||||
}
|
||||
|
||||
private towerStats(t: Tower) {
|
||||
return TOWERS[t.kind].levels[t.level - 1]
|
||||
private towerStats(t: Tower): { damage: number; range: number; rate: number } {
|
||||
const base = TOWERS[t.kind].levels[t.level - 1]
|
||||
const rangeMul = 1 + 0.04 * this.metaLvl('tower_range')
|
||||
const rateMul = 1 + 0.04 * this.metaLvl('tower_speed')
|
||||
if (rangeMul === 1 && rateMul === 1) return base
|
||||
return { damage: base.damage, range: base.range * rangeMul, rate: base.rate * rateMul }
|
||||
}
|
||||
|
||||
private canHit(t: Tower, e: Enemy): boolean {
|
||||
|
|
@ -783,6 +850,9 @@ export class GameEngine {
|
|||
|
||||
/** poison/burn: damage over time, stronger effects replace weaker ones */
|
||||
private applyDot(e: Enemy, dps: number, duration: number, color: string, sourceId: number): void {
|
||||
const pot = 1 + 0.15 * this.metaLvl('dot_potency')
|
||||
dps = Math.round(dps * pot)
|
||||
duration = duration * pot
|
||||
if (this.time < e.dotUntil && e.dotDps > dps) return
|
||||
e.dotDps = dps
|
||||
e.dotUntil = this.time + duration
|
||||
|
|
@ -1069,7 +1139,8 @@ export class GameEngine {
|
|||
if (e.hp <= 0) {
|
||||
e.dead = true
|
||||
this.kills++
|
||||
this.money += e.reward
|
||||
const reward = Math.max(1, Math.round(e.reward * this.goldMul()))
|
||||
this.money += reward
|
||||
this.score += e.reward + this.waveNo * 2
|
||||
if (source) source.kills++
|
||||
this.sfx('death')
|
||||
|
|
@ -1090,7 +1161,7 @@ export class GameEngine {
|
|||
grav: 160,
|
||||
})
|
||||
}
|
||||
this.fx({ type: 'text', x: e.x, y: e.y - 8, vy: -34, life: 0.9, max: 0.9, str: `+${e.reward}`, color: '#ffd23e', size: e.kind === 'boss' ? 17 : 12 })
|
||||
this.fx({ type: 'text', x: e.x, y: e.y - 8, vy: -34, life: 0.9, max: 0.9, str: `+${reward}`, color: '#ffd23e', size: e.kind === 'boss' ? 17 : 12 })
|
||||
if (e.kind === 'boss') {
|
||||
this.shake = 0.8
|
||||
this.fx({ type: 'ring', x: e.x, y: e.y, r0: 10, r1: 90, life: 0.5, max: 0.5, color: '#ffd23e', width: 4 })
|
||||
|
|
@ -1303,7 +1374,7 @@ export class GameEngine {
|
|||
case 'obstacle': {
|
||||
const item = this.obstacleAt(a.tx, a.ty)
|
||||
if (!item) return false
|
||||
const cost = OBSTACLE_COST[item.type]
|
||||
const cost = this.obstacleCost(item.type)
|
||||
if (this.money < cost) {
|
||||
if (local) this.sfx('error')
|
||||
return false
|
||||
|
|
@ -1413,7 +1484,7 @@ export class GameEngine {
|
|||
if (!item) {
|
||||
store.obstacle = null
|
||||
} else {
|
||||
store.obstacle = { tx: obSel.tx, ty: obSel.ty, type: item.type, cost: OBSTACLE_COST[item.type] }
|
||||
store.obstacle = { tx: obSel.tx, ty: obSel.ty, type: item.type, cost: this.obstacleCost(item.type) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
66
src/game/meta.ts
Normal file
66
src/game/meta.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { META_UPGRADES as SHARED_UPGRADES, UPGRADE_BRANCHES as SHARED_BRANCHES, calcCrystalsEarned } from '../../shared/meta-upgrades.mjs'
|
||||
import type { MetaBranchId, MetaUpgradeDef, MetaUpgradeId, UserMetaProfile } from './types'
|
||||
|
||||
export { calcCrystalsEarned }
|
||||
|
||||
export const UPGRADE_BRANCHES = SHARED_BRANCHES as { id: MetaBranchId; name: string; icon: string; desc: string }[]
|
||||
|
||||
/** human-readable effect per level (frontend display only) */
|
||||
const EFFECT_DESC: Record<MetaUpgradeId, (lvl: number) => string> = {
|
||||
start_gold: (lvl) => `+${lvl * 35} Startgold`,
|
||||
wave_bonus: (lvl) => `+${lvl * 6}% Wellen-Gold`,
|
||||
obstacle_discount: (lvl) => `−${lvl * 15}% Hindernis-Kosten`,
|
||||
bonus_lives: (lvl) => `+${lvl * 3} maximale Leben`,
|
||||
shockwave: (lvl) => `${25 + lvl * 25}% Verlangsamung für ${1 + lvl}s bei Lebensverlust`,
|
||||
fortress_shield: () => 'Blockt 1 Durchbruch alle 5 Wellen',
|
||||
tower_range: (lvl) => `+${lvl * 4}% Turmreichweite`,
|
||||
tower_speed: (lvl) => `+${lvl * 4}% Angriffsrate`,
|
||||
dot_potency: (lvl) => `+${lvl * 15}% Gift-/Brandschaden & Dauer`,
|
||||
}
|
||||
|
||||
export const META_UPGRADES: Record<MetaUpgradeId, MetaUpgradeDef> = Object.fromEntries(
|
||||
Object.values(SHARED_UPGRADES).map((u) => {
|
||||
const id = u.id as MetaUpgradeId
|
||||
return [id, { ...u, id, effectDesc: EFFECT_DESC[id] }]
|
||||
}),
|
||||
) as Record<MetaUpgradeId, MetaUpgradeDef>
|
||||
|
||||
/** Local guest profile storage (pre-login progress) */
|
||||
const GUEST_PROFILE_KEY = 'trxtd-guest-profile'
|
||||
|
||||
export function emptyProfile(): UserMetaProfile {
|
||||
return {
|
||||
id: 'guest',
|
||||
username: 'Gast',
|
||||
displayName: 'Gastspieler',
|
||||
crystals: 0,
|
||||
upgrades: {},
|
||||
stats: { gamesPlayed: 0, gamesWon: 0, totalKills: 0, totalScore: 0, highestWave: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
export function loadGuestProfile(): UserMetaProfile {
|
||||
if (typeof localStorage === 'undefined') return emptyProfile()
|
||||
try {
|
||||
const raw = localStorage.getItem(GUEST_PROFILE_KEY)
|
||||
if (raw) {
|
||||
const p = JSON.parse(raw) as UserMetaProfile
|
||||
if (p && typeof p.crystals === 'number' && p.upgrades && p.stats) return p
|
||||
}
|
||||
} catch {
|
||||
/* corrupted -> fresh profile */
|
||||
}
|
||||
return emptyProfile()
|
||||
}
|
||||
|
||||
export function saveGuestProfile(p: UserMetaProfile): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(GUEST_PROFILE_KEY, JSON.stringify(p))
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGuestProfile(): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(GUEST_PROFILE_KEY)
|
||||
}
|
||||
}
|
||||
|
|
@ -162,6 +162,7 @@ class MpGameController {
|
|||
const chosenMap = info.mapId || store.mp.mapId || 'meadow'
|
||||
|
||||
if (info.mode === 'coop') {
|
||||
engine.setMeta({}) // no meta bonuses in multiplayer (fairness + determinism)
|
||||
engine.startGame('normal', chosenMap)
|
||||
engine.mpGameActive = true
|
||||
engine.mpMode = 'coop'
|
||||
|
|
@ -171,12 +172,14 @@ class MpGameController {
|
|||
this.engines = [engine]
|
||||
this.remoteEngine = null
|
||||
} else {
|
||||
engine.setMeta({})
|
||||
engine.startGame('normal', chosenMap)
|
||||
engine.mpGameActive = true
|
||||
engine.mpMode = 'duel'
|
||||
engine.localPlayerId = info.you
|
||||
engine.mpController = this
|
||||
const remote = new GameEngine()
|
||||
remote.setMeta({})
|
||||
remote.startGame('normal', chosenMap)
|
||||
remote.mpGameActive = true
|
||||
remote.mpMode = 'duel'
|
||||
|
|
@ -368,6 +371,7 @@ class MpGameController {
|
|||
kills: engine.kills,
|
||||
best: 0,
|
||||
bestBefore: 0,
|
||||
crystalsEarned: 0,
|
||||
}
|
||||
store.screen = win ? 'victory' : 'gameover'
|
||||
sound.play(win ? 'victory' : 'defeat')
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export type SfxName =
|
|||
| 'defeat'
|
||||
| 'click'
|
||||
| 'error'
|
||||
| 'shield'
|
||||
|
||||
class SoundManager {
|
||||
private ctx: AudioContext | null = null
|
||||
|
|
@ -164,6 +165,10 @@ class SoundManager {
|
|||
case 'error':
|
||||
this.tone(160, 0.12, 'square', 0.1, 120)
|
||||
break
|
||||
case 'shield':
|
||||
this.tone(700, 0.1, 'sine', 0.12, 1200)
|
||||
this.tone(1400, 0.16, 'sine', 0.1, 900, 0.08)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { reactive } from 'vue'
|
||||
import type { DifficultyId, EnemyKind, MapId, MPMode, Phase, Screen, SelectedTowerInfo, TowerKind } from './types'
|
||||
import type { DifficultyId, EnemyKind, MapId, MPMode, Phase, Screen, SelectedTowerInfo, TowerKind, UserMetaProfile } from './types'
|
||||
|
||||
/**
|
||||
* Reactive bridge between the (non-reactive) game engine and the Vue UI.
|
||||
|
|
@ -13,6 +13,17 @@ export const store = reactive({
|
|||
difficulty: 'normal' as DifficultyId,
|
||||
mapId: 'meadow' as MapId,
|
||||
|
||||
/** account / meta-progression state */
|
||||
auth: {
|
||||
checked: false,
|
||||
user: null as UserMetaProfile | null,
|
||||
oidcEnabled: false,
|
||||
oidcLabel: 'Mit Single Sign-On anmelden',
|
||||
showAuth: false,
|
||||
showResearch: false,
|
||||
authStatus: '',
|
||||
},
|
||||
|
||||
money: 0,
|
||||
lives: 0,
|
||||
maxLives: 0,
|
||||
|
|
@ -35,7 +46,7 @@ export const store = reactive({
|
|||
/** composition of the upcoming wave, for the preview chips */
|
||||
nextPreview: [] as { kind: EnemyKind; count: number }[],
|
||||
|
||||
result: null as { win: boolean; score: number; wave: number; kills: number; best: number; bestBefore: number } | null,
|
||||
result: null as { win: boolean; score: number; wave: number; kills: number; best: number; bestBefore: number; crystalsEarned: number } | null,
|
||||
|
||||
/** multiplayer state (lobby + in-game info) */
|
||||
mp: {
|
||||
|
|
|
|||
|
|
@ -258,3 +258,44 @@ export interface DecorItem {
|
|||
s: number
|
||||
seed: number
|
||||
}
|
||||
|
||||
export type MetaBranchId = 'economy' | 'defense' | 'towers'
|
||||
|
||||
export type MetaUpgradeId =
|
||||
| 'start_gold'
|
||||
| 'wave_bonus'
|
||||
| 'obstacle_discount'
|
||||
| 'bonus_lives'
|
||||
| 'shockwave'
|
||||
| 'fortress_shield'
|
||||
| 'tower_range'
|
||||
| 'tower_speed'
|
||||
| 'dot_potency'
|
||||
|
||||
export interface MetaUpgradeDef {
|
||||
id: MetaUpgradeId
|
||||
name: string
|
||||
branch: MetaBranchId
|
||||
icon: string
|
||||
desc: string
|
||||
maxLevel: number
|
||||
costs: number[] // cost in crystals for level 1..maxLevel
|
||||
effectDesc: (lvl: number) => string
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
gamesPlayed: number
|
||||
gamesWon: number
|
||||
totalKills: number
|
||||
totalScore: number
|
||||
highestWave: number
|
||||
}
|
||||
|
||||
export interface UserMetaProfile {
|
||||
id: string
|
||||
username: string
|
||||
displayName: string
|
||||
crystals: number
|
||||
upgrades: Partial<Record<MetaUpgradeId, number>>
|
||||
stats: UserStats
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
import { initAuth } from './game/auth'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
void initAuth()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue