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

@ -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>

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

View file

@ -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;

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

View file

@ -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;
}

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

View file

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

View file

@ -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')

View file

@ -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
}
}
}

View file

@ -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: {

View file

@ -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
}

View file

@ -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()