TRXTD/shared/meta-upgrades.mjs
Tronax 1a9ac5bf45
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)
2026-08-16 16:22:38 +02:00

108 lines
3.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
}