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:
Tronax 2026-08-16 16:22:38 +02:00
parent ce2484bb8d
commit 1a9ac5bf45
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
24 changed files with 2216 additions and 12 deletions

View 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
View 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)
}