Commit graph

16 commits

Author SHA1 Message Date
69fbb015ab
feat(admin): live admin dashboard with solo presence tracking
Solo games run entirely in the browser, so the server previously had no
idea who was playing right now (it only saw logins and finished games).
This adds lightweight presence reporting and an admin dashboard.

Presence (server):
- POST /api/presence (logged-in users): heartbeat while a solo game runs,
  stores username, map (validated), difficulty, wave, since/lastSeen per
  user; entries expire automatically after 90s without a heartbeat
- POST /api/presence/stop: explicit removal when the player exits

Admin API (ADMIN_USERS env, comma-separated usernames):
- GET /api/admin/overview: live solo players, multiplayer room summaries
  (code, mode, map, players — the server already tracks rooms), and
  global stats (accounts, rounds, crystals in circulation)
- GET /api/admin/users?limit=100: user list with stats, newest login first
- publicUser now carries an admin flag; non-admins get 403

Admin UI (src/components/AdminDashboard.vue, served at /admin):
- Login gate for guests/non-admins (guest profiles are detected via
  isLoggedIn, not just user presence)
- KPI cards, live solo table (player, map, difficulty, wave, duration),
  room table, and account table; auto-refresh every 5 seconds
- App.vue renders the dashboard for /admin instead of the game and runs
  a screen watcher that starts/stops the solo presence heartbeat

Config: ADMIN_USERS documented in docker-compose.yml and README.

Tests: 6 new integration checks (admin flag, presence report/stop,
403 guard, overview contents, user list) — 37/37 green, build clean.
Verified end-to-end in the browser: guest gate, admin login, and a live
second player (map/difficulty/wave) appearing in the dashboard.
2026-08-17 15:07:02 +02:00
31cb594cb0
feat(pwa): installable app + fix research tree pip overflow
Research tree UI:
- The level pips shared the header row with the upgrade name and fought
  for width; long names like "Festungsmauern" (5 pips) pushed the pips
  out of the upgrade card. Pips now sit in the bottom row next to the
  buy button (space-between), where width is plentiful, and carry a
  "level / max" tooltip
- Tighter modal padding on phones (max-width 560px), scrollable at 92vh

PWA (installable as app in mobile browsers):
- public/manifest.webmanifest: standalone display, any orientation,
  TRXTD theme colors, German lang, start_url "/"
- public/sw.js: network-first service worker with runtime caching.
  index.html is always fetched fresh so new deploys are picked up
  immediately; the cache only serves as offline fallback. API calls
  and cross-origin requests are never intercepted
- Icon set generated by scripts/generate-icons.mjs (pure Node PNG
  encoder, zero image dependencies): gold tower on the game-themed
  dark gradient in 192px, 512px, maskable 512px (motif inside the safe
  zone) and 180px apple-touch-icon
- index.html: manifest link, favicon, apple-touch-icon, theme-color,
  mobile-web-app-capable, apple-mobile-web-app meta tags
- src/main.ts: service worker registration in production builds only
  (dev HMR stays untouched)
- server.mjs: serve .webmanifest as application/manifest+json (Chrome
  requires the correct MIME type for installability)

Verified against the production server: manifest (application/
manifest+json), icons (image/png) and sw.js (text/javascript) respond
with 200, index.html references all PWA tags; npm test 48/48 green.
2026-08-17 14:53:58 +02:00
914af42210
feat(ui): mobile-friendly game layout for portrait and landscape phones
The game layout stacked HUD, board, and tower shop vertically with
desktop-sized chrome, leaving a tiny unusable board on phones (about
250x136px in landscape). Touch input and responsive canvas scaling
already existed — only the surrounding layout blocked mobile play.

Landscape phones / short viewports (max-height: 560px):
- New .game-main wrapper switches to a row layout: the board keeps the
  full remaining width and the tower shop becomes a vertical column on
  the right edge with one compact row per tower (icon + name + cost)
- HUD compacts to a single row (no 78px wave section, no preview chips,
  smaller stats and buttons); layout padding and gaps shrink
- Board grows from ~250x136 to ~487x268 on a 740x360 viewport

Portrait phones (max-width: 560px):
- Tower shop renders five compact icon tiles in a single row (69px each,
  no horizontal overflow)
- HUD wraps into compact rows; difficulty chip and preview hidden

All screens:
- Overlay panels (tower info, obstacle, pause, end screen) may now scale
  down to 0.65 via --ui-scale so they no longer cover a small board
  (previously clamped to a minimum of 1)

Verified in a real browser: landscape 740x360 (board 487x268, shop column
right), portrait 390x844 (board 366x201, shop row fits, no overflow),
desktop 1280x800 unchanged (column layout, board 1020x561). Touch flow
tested end-to-end: tap shop card -> tap board builds a tower (gold
260->210), tap tower opens the scaled info panel with upgrade/sell.
2026-08-16 19:18:11 +02:00
4c14425a6d
fix(ui): allow scrolling on start and lobby screens for small viewports
The app container had a fixed height: 100dvh with overflow: hidden, which
is correct for the game layout but cut off the start screen on small
viewports (mobile phones) — map cards, difficulty selection, and the help
section were unreachable with no way to scroll.

Changes:
- App.vue: add a "scrollable" class (overflow-y: auto) to the app container
  when the screen is "menu" or "lobby"; the in-game layout keeps the fixed
  locked viewport
- LobbyScreen.vue: change .lobby height: 100dvh to min-height: 100dvh so a
  tall lobby card grows instead of being clipped by flex centering on small
  screens

Verified in a real browser at 390x700 (mobile viewport): the menu content
overflows (2261px vs 700px viewport), .app reports overflow-y: auto and is
scrollable, and after a simulated swipe the help section at the bottom
becomes visible (scrollTop 900 of max 1561).
2026-08-16 18:30:10 +02:00
cf63f27839
fix(auth): split login and signup into separate 1Password-compatible forms
1Password showed "No items to display" on the signup password field
because login and registration shared a single form whose password input
switched autocomplete between current-password and new-password, and the
display-name field was dynamically added via v-if. Per 1Password compatible
website design, unrelated flows must be separate forms with stable fields.

Changes:
- Split into two distinct <form> elements: login (autocomplete
  current-password) and signup (autocomplete new-password), each with
  unique field ids so 1Password does not cache the field as a login field
- Add passwordrules, minlength=8, and maxlength=128 to the signup password
  input so 1Password can generate a password matching the server rules
  (min 8 chars)
- Remove the dynamic autocomplete computed and the v-if display-name field;
  each form now has a static, complete field set
- Add .fields form styling (flex column, gap) now that the forms sit inside
  the card container
2026-08-16 16:58:51 +02:00
f97b4f92f0
fix(auth): make login/signup fields password-manager friendly
Password managers (1Password, Bitwarden, …) could not reliably detect or
fill the auth fields because the inputs were loose labels inside a div with
no real form, no name attributes, and a static autocomplete hint.

Changes:
- Wrap the auth card in a real <form @submit.prevent> so password managers
  recognize the credential form and its submit flow
- Add id/name attributes to all fields (username, displayName, password)
- Use a dynamic autocomplete hint on the password field:
  current-password for login (autofill) and new-password for registration
  (offer a generated strong password)
- Set autocomplete="nickname" on the display-name field so it is not
  mistaken for a username or password field
- Mark the close, tab-switch, and OIDC buttons as type="button" so they no
  longer default to type="submit" and trigger form submission
- Make the primary action a type="submit" button so Enter submits natively
- Add margin: 0 to .card since it is now a form element
2026-08-16 16:51:16 +02:00
d400a4a2a0
fix(docker): harden data-dir permissions and surface actionable DB errors
The bind-mount permission fix only takes effect after a rebuild, so a
stale image keeps failing with the opaque "unable to open database file".
This hardens both sides so any remaining failure is self-explanatory:

- docker-entrypoint.sh: fall back to chmod 777 when chown is unsupported
  (network/9p mounts), and fall back to running as root when su-exec is
  unavailable, so the data dir is always writable on any filesystem.
- server/db.mjs: wrap the DatabaseSync open in a try/catch and, on
  failure, report the exact path, whether the directory is writable, and
  the process UID instead of the bare SQLite error.

Verified with a real container: a normal bind mount serves /health and
creates trxtd.db; a read-only mount now prints the directory-permission
diagnostic instead of the raw SQLite error.
2026-08-16 16:40:45 +02:00
1abc08b60f
fix(docker): make /app/data writable by node user on bind-mounted volumes
The container ran as the unprivileged "node" user, but bind-mounted host
directories (e.g. ./data:/app/data in docker-compose or an Unraid appdata
folder) are mounted with root ownership. The node user therefore could not
create trxtd.db in /app/data, failing at startup with:

  Error: unable to open database file
      at file:///app/server/db.mjs:12

Fix:
- Add docker-entrypoint.sh that mkdir/chown /app/data to node:node on
  container start, then drops privileges back to node via su-exec
- Install su-exec in the runtime stage (apk add --no-cache su-exec)
- Remove USER node so the entrypoint runs as root and can fix ownership,
  with privilege dropping handled inside the entrypoint instead
- Wire the script up as ENTRYPOINT, keeping CMD as the app itself

The mount path itself is unchanged (/app/data, host ./data). Verified with
a real bind mount: /health responds ok and trxtd.db/-shm/-wal are created
in the mounted directory.
2026-08-16 16:35:08 +02:00
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
ce2484bb8d
feat: add multi-map system with 4 distinct biomes, layouts, and multiplayer sync
- Introduce 4 playable map environments:
  * Meadow (Grüne Lichtung): Classic balanced S-curve path with lush grasslands, flowers, and standard obstacles
  * Desert (Sonnendünen): Winding dune trail with sandstone rocks, oasis palms, and scrub
  * Frostland (Frostgipfel): Icy serpentine path through snowfields with frost-covered pines and crystal rocks
  * Volcano (Lavabruch): Tight aggressive layout through dark obsidian/basalt with glowing magma streams and floating ember sparks
- Add comprehensive MapDef & MapTheme configuration for distinct waypoint routes, flying trajectories, biome color palettes, portal/keep styles, and obstacle densities
- Extend Canvas 2D renderer to dynamically adapt backgrounds, path texturing, portal effects, and procedurally drawn biome foliage/decorations per map
- Upgrade GameEngine with dynamic map loading, obstacle clearing logic, and per-map highscore persistence
- Implement multiplayer map synchronization in WebSocket server (set-map event), room state, and lobby UI allowing host map selection
- Update StartScreen and LobbyScreen components with interactive map selection cards and per-map highscore tracking
- Add automated test suites for map path integrity, determinism, playability, and room map protocol synchronization
2026-08-16 15:03:34 +02:00
a7fb3efa61
ui: remove technical terminology and CLI references from user interface 2026-08-16 14:10:51 +02:00
4f8400c480
feat: add 9-level tower evolution system with distinct tier abilities
Extend all 5 towers to 9 upgrade levels, divided into 3 visual and
functional evolution tiers:
- Tier 1 (L1-3, Yellow): Base stats (damage, range, rate).
- Tier 2 (L4-6, Orange): First major ability evolution with pulsing orange aura.
- Tier 3 (L7-9, Blue): Final devastating evolution with blue aura.

Evolutions per tower:
- Arrow Tower:
  * L4-6 (Orange): Multishot firing 2-3 arrows simultaneously at distinct targets.
  * L7-9 (Blue): 4-arrow multishot + Poison DoT (up to 28 dmg/s).
- Cannon:
  * L4-6 (Orange): Incendiary shells applying Burn DoT in splash radius.
  * L7-9 (Blue): Flak shells hitting flying units with full splash + burn damage.
- Frost Tower:
  * L4-6 (Orange): Permafrost - every 3rd pulse completely freezes enemies (speed = 0).
  * L7-9 (Blue): Shatter - frozen enemies receive +50% damage from all sources.
- Tesla Tower:
  * L4-6 (Orange): Chain lightning applies stun on each hit.
  * L7-9 (Blue): Lightning Storm - every 4th shot strikes and stuns ALL enemies in range.
- Laser Tower:
  * L4-6 (Orange): Prism refraction splitting the beam onto 2-3 secondary targets.
  * L7-9 (Blue): Piercing beam penetrating all enemies in a line up to 280px range.

Engine & Renderer updates:
- Added DoT tick handling (poison/burn) and freeze/stun status effects.
- Added visual indicators for frozen enemies (ice spikes) and DoT particles.
- Added 3-slot tier-colored pips on tower base plates + pulsating evolution auras.
- Updated TowerPanel with 9-star tier display (★★★ yellow, ★★★ orange, ★★★ blue)
  and dynamic special ability descriptions.

Testing & Validation:
- Added `scripts/test-evolution.mts` covering all 10 evolved mechanics (all green).
- Verified lockstep multiplayer determinism via `scripts/test-mp.mts`.
- Verified solo campaign balance preservation via `scripts/sim.mts`.
- Full TypeScript typecheck and production build passing.
2026-08-16 13:54:09 +02:00
ef75203db7
feat(duel): allow swapping main view to opponent's board
- Add a toggle on the picture-in-picture window to swap the big board
  with the mini view of the opponent's field
- Color-coded frame and badge identify whose board is currently shown
  (blue = player 1, orange = player 2)
- Block building and rushing while spectating the opponent's board;
  the PiP then shows your own field instead
- Clarify duel-mode descriptions in lobby and start screen, add hints
  in the multiplayer bar
- Replace previous announcement banners so only one shows at a time
2026-08-16 13:35:46 +02:00
4ec0e483aa
feat(server): add security hardening for public hosting
- Add CSP, frame and referrer headers for served static files
- Enforce WebSocket origin check to prevent cross-site hijacking
- Trust X-Forwarded-For only when the peer is from a private proxy network
- Limit concurrent connections (500 total / 20 per IP) and rooms (300)
- Add ALLOWED_ORIGINS env var for additional WebSocket origins
- Document reverse proxy setup (NPM/NPMplus) in README
- Add scripts/security-test.mjs to verify origin and limit behavior
2026-08-16 13:16:20 +02:00
b506ffad55
feat(docker): add self-hosted all-in-one container setup
- Add Dockerfile (multi-stage build, unprivileged user, healthcheck),
  docker-compose.yml and .dockerignore
- Server now serves static dist/ frontend and WebSocket relay on a
  single port (PORT, default 3001) with path-traversal protection,
  immutable asset caching and SPA fallback
- Client connects via same origin in production; dedicated ws port is
  only used for vite dev/preview
- Document Docker usage, env vars and manual production mode in README
2026-08-16 12:15:52 +02:00
c4347f8420
feat: initialize TRXTD browser tower defense 2026-08-15 16:07:09 +02:00