diff --git a/.gitignore b/.gitignore index 889b30b..bf8a53f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ +data/ +backups/ game_assets/ *.tsbuildinfo vite.config.js diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index fdc5324..9325df3 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -4,6 +4,17 @@ This game uses the same proven local-Gitea pattern as `testgame`: clone from the Gitea bare repository on the TrueNAS filesystem, mount that working checkout into one Node container, and update it with a local Git pull plus app restart. +## What the TrueNAS server does + +The `iwanttoheal-mmo` TrueNAS app is the game's live online server. One Node +process serves the browser bundle and authenticated `/api` routes on port `4173`. +The reverse proxy exposes both at `https://iwanttoheal.phenomrom.com`. + +SQLite persists accounts, sessions, cloud saves, boss-kill rankings, and +roguelike records under `/app/data/game.db`. The separate data mount survives +container replacement. TrueNAS Gitea remains the source repository used for +deployment; it is not the game database. + ## Paths ```text @@ -13,6 +24,9 @@ Local Gitea bare repository: Runnable working checkout: /mnt/usbssds/apps/iwanttoheal-mmo/app +Persistent game data: +/mnt/usbssds/apps/iwanttoheal-mmo/data + Public URL: https://iwanttoheal.phenomrom.com @@ -39,7 +53,7 @@ sudo find /mnt -type d -name "i-want-to-heal-mmo.git" -prune -print 2>/dev/null Clone entirely through the local filesystem: ```sh -sudo mkdir -p /mnt/usbssds/apps/iwanttoheal-mmo +sudo mkdir -p /mnt/usbssds/apps/iwanttoheal-mmo/{app,data} sudo git config --global --add safe.directory \ /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git sudo git clone \ @@ -72,8 +86,10 @@ services: iwanttoheal: image: node:24-bookworm-slim command: >- - sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run build && corepack pnpm start" + sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run db:init && corepack pnpm run build && corepack pnpm start" environment: + CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost" + DATA_DIR: /app/data HOST: 0.0.0.0 PORT: "4173" init: true @@ -82,11 +98,21 @@ services: restart: unless-stopped volumes: - /mnt/usbssds/apps/iwanttoheal-mmo/app:/app + - /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data working_dir: /app ``` -This game has no server database. Do not add `db:init`, `/app/data`, cookie, -CORS, or proxy environment settings from the older game. +Do not remove the `/app/data` mount or place the SQLite database inside the source +checkout. Back up `/mnt/usbssds/apps/iwanttoheal-mmo/data/game.db` before database +migrations or destructive maintenance. + +From the app checkout, create a consistent SQLite backup with: + +```sh +DATA_DIR=/mnt/usbssds/apps/iwanttoheal-mmo/data \ +BACKUP_DIR=/mnt/usbssds/apps/iwanttoheal-mmo/backups \ +corepack pnpm run db:backup +``` The separate volume protects the old app files and data. The YAML still maps host port `4173`, so the old and MMO apps cannot run simultaneously while both diff --git a/README.md b/README.md index 7d26387..ca4a057 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,23 @@ Playable low-poly third-person combat vertical slice for AYN Thor's dual displays. -Offline-first frontend includes three timestamped save slots, optional account sync, local/online overwrite controls, Hunter Profile statistics, boss collection logs, Settings, and PvE/PvP mode entry points. +Offline-first frontend includes three timestamped save slots, TrueNAS accounts +and cloud saves, Hunter Profile statistics, boss and roguelike leaderboards, +boss collection logs, Settings, and PvE/PvP mode entry points. Offline saves +remain playable without an account and can be uploaded after sign-in. ## Run ```bash pnpm install +pnpm dev:api +# In a second terminal: pnpm dev ``` -The development server listens on `0.0.0.0:4173`. +The Vite development server listens on `0.0.0.0:4173` and proxies `/api` to the +local production/API server on `127.0.0.1:4174`. Both processes use the same +client API contract as TrueNAS. ## Android / AYN Thor test APK @@ -23,6 +30,10 @@ installable debug APK: pnpm android:apk ``` +Build explicitly against the TrueNAS API with `pnpm android:apk:truenas`. +Native builds also default to `https://iwanttoheal.phenomrom.com` when no API +base override is supplied. + Output is written under `android/app/build/outputs/apk/debug/`. With Android platform tools and a connected Thor, build and install it with: @@ -41,12 +52,25 @@ state rather than two independent WebViews. Complete first-install, local-Gitea clone, YAML, update, and verification steps: [DEPLOYMENT.md](DEPLOYMENT.md). -The production preview server uses the existing deployment address and port: +The live TrueNAS web server uses the existing deployment address and port: - Public URL: `https://iwanttoheal.phenomrom.com` - Host/container port: `4173` - App directory: `/mnt/usbssds/apps/iwanttoheal-mmo/app` +### Server architecture + +TrueNAS is the online production game server. Its Node process serves the browser +application and authenticated `/api` routes on port `4173`; the reverse proxy +exposes both at the public URL above. SQLite data persists at `/app/data/game.db` +through the separate TrueNAS data mount. + +The server owns account credentials, 30-day sessions, three cloud-save slots per +account, boss-kill rankings, and roguelike highest-round rankings. Passwords use +scrypt with per-account salts; clients store only opaque session tokens. Local +saves remain available for offline play. Gitea is a separate TrueNAS service used +for source hosting and deployment. + Clone from the TrueNAS-local Gitea bare repository into the app directory, then deploy `compose.yaml`. The expected source path is: @@ -54,18 +78,16 @@ deploy `compose.yaml`. The expected source path is: /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git ``` -Compared with the old game configuration: +Current configuration: - use `corepack pnpm install --frozen-lockfile`, not `npm ci`; -- remove `npm run db:init` because this game has no server database; -- remove `COOKIE_SECURE`, `CORS_ORIGINS`, and `TRUST_PROXY`; the static Vite - preview server does not consume them; -- remove `/app/data`; offline saves live in each player's browser/Android WebView - storage, not on TrueNAS. +- run `pnpm db:init` before starting the production server; +- mount `/mnt/usbssds/apps/iwanttoheal-mmo/data` at `/app/data`; +- keep `CORS_ORIGINS` configured for the public site and Capacitor host; +- keep the source checkout and persistent data in separate mounts. -The existing reverse proxy can keep forwarding the public hostname to port -`4173`. Add server data and authentication environment variables only when an -actual sync API is introduced. +The existing reverse proxy forwards the public hostname to port `4173`, including +all `/api` routes. ## Publish updates to Gitea @@ -116,11 +138,11 @@ outside the repository. Touch controls on lower display support party targeting, ability casting, map, and inventory. -## Prototype scope +## Current game scope - Five-member AI party with Disc Priest healer - Animated Druid healer, Knight tank, Ranger, Rogue, and Mage party models -- One animated boss: Bulldrome, using the Bull model at 180% of its original prototype scale +- Animated bosses using canonical tracked game models - Telegraph, charge, 0.75-second knockdown, and return-to-tank behavior - Three-charge cycle into a five-second stack marker and 300-damage shared pounce - Tank pressure, party-wide Cinder Nova, dispellable Ember Brand diff --git a/android/app/src/main/java/com/phenomrom/iwanttoheal/ControllerBridgeActivity.java b/android/app/src/main/java/com/phenomrom/iwanttoheal/ControllerBridgeActivity.java index e9ac063..267c8b0 100644 --- a/android/app/src/main/java/com/phenomrom/iwanttoheal/ControllerBridgeActivity.java +++ b/android/app/src/main/java/com/phenomrom/iwanttoheal/ControllerBridgeActivity.java @@ -102,7 +102,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity { float leftStickX = event.getAxisValue(MotionEvent.AXIS_X); float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y); - dispatchNativeControllerMotion(leftStickX, leftStickY); + float rightStickX = controllerAxisValue(event, MotionEvent.AXIS_Z, MotionEvent.AXIS_RX); + float rightStickY = controllerAxisValue(event, MotionEvent.AXIS_RZ, MotionEvent.AXIS_RY); + dispatchNativeControllerMotion(leftStickX, leftStickY, rightStickX, rightStickY); Set currentTokens = new HashSet<>(); addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_X), "Button14", "Button15"); @@ -150,6 +152,14 @@ public abstract class ControllerBridgeActivity extends BridgeActivity { if (value >= AXIS_DEAD_ZONE) tokens.add(positive); } + private float controllerAxisValue(MotionEvent event, int primaryAxis, int fallbackAxis) { + InputDevice device = event.getDevice(); + if (device != null && device.getMotionRange(primaryAxis) != null) { + return event.getAxisValue(primaryAxis); + } + return event.getAxisValue(fallbackAxis); + } + private void dispatchNativeControllerToken(String token, boolean repeat) { if (bridge == null || bridge.getWebView() == null) return; String script = @@ -161,18 +171,19 @@ public abstract class ControllerBridgeActivity extends BridgeActivity { }); } - private void dispatchNativeControllerMotion(float x, float y) { + private void dispatchNativeControllerMotion(float moveX, float moveY, float lookX, float lookY) { if (bridge == null || bridge.getWebView() == null) return; String script = "window.dispatchEvent(new CustomEvent('iwt-native-controller-motion'," - + "{detail:{x:" + x + ",y:" + y + "}}));"; + + "{detail:{moveX:" + moveX + ",moveY:" + moveY + + ",lookX:" + lookX + ",lookY:" + lookY + "}}));"; bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null)); } private void clearHeldControllerState() { activeMotionTokens.clear(); lastMotionDispatchAt.clear(); - dispatchNativeControllerMotion(0.0f, 0.0f); + dispatchNativeControllerMotion(0.0f, 0.0f, 0.0f, 0.0f); if (bridge == null || bridge.getWebView() == null) return; bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript( "window.dispatchEvent(new Event('iwt-native-controller-reset'));", diff --git a/compose.yaml b/compose.yaml index b7049db..013281b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,8 +2,10 @@ services: iwanttoheal: image: node:24-bookworm-slim command: >- - sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run build && corepack pnpm start" + sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run db:init && corepack pnpm run build && corepack pnpm start" environment: + CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost" + DATA_DIR: /app/data HOST: 0.0.0.0 PORT: "4173" init: true @@ -12,4 +14,5 @@ services: restart: unless-stopped volumes: - /mnt/usbssds/apps/iwanttoheal-mmo/app:/app + - /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data working_dir: /app diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..3c7073b --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,56 @@ +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; + +CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL, + canonical_username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + password_salt TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS sessions_token_hash_idx ON sessions(token_hash); +CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at); + +CREATE TABLE IF NOT EXISTS hunter_saves ( + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3), + hunter_name TEXT NOT NULL, + save_json TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account_id, slot_id) +); + +CREATE TABLE IF NOT EXISTS boss_kill_records ( + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3), + boss_id TEXT NOT NULL, + kills INTEGER NOT NULL DEFAULT 0 CHECK (kills >= 0), + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account_id, slot_id, boss_id), + FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS boss_kill_rank_idx + ON boss_kill_records (boss_id, kills DESC, updated_at ASC); + +CREATE TABLE IF NOT EXISTS roguelike_records ( + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3), + highest_round INTEGER NOT NULL DEFAULT 0 CHECK (highest_round >= 0), + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account_id, slot_id), + FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS roguelike_rank_idx + ON roguelike_records (highest_round DESC, updated_at ASC); diff --git a/package.json b/package.json index b1a3e86..ed82459 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,22 @@ { "name": "i-want-to-heal", "private": true, - "version": "0.1.5", + "version": "0.1.6", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", + "dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs", + "db:backup": "node scripts/backup-db.mjs", + "db:init": "node scripts/init-db.mjs", "build": "tsc -b && vite build", "android:sync": "pnpm run build && cap sync android", + "android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync", "android:apk": "pnpm run android:sync && cd android && ./gradlew --no-daemon clean assembleDebug", + "android:apk:truenas": "pnpm run android:sync:truenas && cd android && ./gradlew --no-daemon clean assembleDebug", "android:install": "pnpm run android:apk && adb install -r android/app/build/outputs/apk/debug/*.apk", - "start": "vite preview --host ${HOST:-0.0.0.0} --port ${PORT:-4173} --strictPort", + "start": "node server/production.mjs", "publish:gitea": "python3 scripts/publish_gitea.py", - "test": "vitest run", + "test": "vitest run && node --test server/game-api.test.mjs", "test:watch": "vitest", "assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write", "assets:import": "node scripts/import-game-asset.mjs" diff --git a/scripts/backup-db.mjs b/scripts/backup-db.mjs new file mode 100644 index 0000000..b9db737 --- /dev/null +++ b/scripts/backup-db.mjs @@ -0,0 +1,17 @@ +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +const dataDirectory = resolve(process.env.DATA_DIR ?? "data"); +const backupDirectory = resolve(process.env.BACKUP_DIR ?? "backups"); +const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-"); +const backupPath = resolve(backupDirectory, `game-${timestamp}.db`); +mkdirSync(backupDirectory, { recursive: true }); + +const database = new DatabaseSync(resolve(dataDirectory, "game.db")); +try { + database.exec(`VACUUM INTO '${backupPath.replaceAll("'", "''")}'`); + console.log(`SQLite backup created: ${backupPath}`); +} finally { + database.close(); +} diff --git a/scripts/init-db.mjs b/scripts/init-db.mjs new file mode 100644 index 0000000..697751d --- /dev/null +++ b/scripts/init-db.mjs @@ -0,0 +1,14 @@ +import { mkdirSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +const dataDirectory = resolve(process.env.DATA_DIR ?? "data"); +mkdirSync(dataDirectory, { recursive: true }); + +const database = new DatabaseSync(resolve(dataDirectory, "game.db")); +const schema = await readFile(new URL("../db/schema.sql", import.meta.url), "utf8"); +database.exec(schema); +database.close(); + +console.log(`Database ready: ${resolve(dataDirectory, "game.db")}`); diff --git a/server/game-api.mjs b/server/game-api.mjs new file mode 100644 index 0000000..ec7e28c --- /dev/null +++ b/server/game-api.mjs @@ -0,0 +1,400 @@ +import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; +import { mkdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; +const MAX_JSON_BYTES = 1024 * 1024; +const AUTH_WINDOW_MS = 15 * 60 * 1000; +const AUTH_ATTEMPTS_PER_WINDOW = 20; +const authAttempts = new Map(); + +function apiError(message, status = 400) { + const error = new Error(message); + error.status = status; + return error; +} + +function sendJson(response, status, body) { + response.statusCode = status; + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.setHeader("Cache-Control", "no-store"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.end(JSON.stringify(body)); +} + +function configuredCorsOrigins() { + return String(process.env.CORS_ORIGINS ?? "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); +} + +function setCorsHeaders(request, response) { + const origin = request.headers.origin; + if (!origin) return; + const configured = configuredCorsOrigins(); + if (!configured.includes("*") && !configured.includes(origin)) return; + response.setHeader("Access-Control-Allow-Origin", origin); + response.setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type"); + response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); + response.setHeader("Access-Control-Max-Age", "86400"); + response.setHeader("Vary", "Origin"); +} + +async function readJson(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > MAX_JSON_BYTES) throw apiError("Request body is too large.", 413); + chunks.push(chunk); + } + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + throw apiError("Request body must be valid JSON."); + } +} + +function canonicalUsername(value) { + return String(value ?? "").trim().toLocaleLowerCase(); +} + +function validateUsername(value) { + const username = String(value ?? "").trim(); + if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) { + throw apiError("Username must be 3–20 letters, numbers, or underscores."); + } + return username; +} + +function validatePassword(value) { + const password = String(value ?? ""); + if (password.length < 10 || password.length > 128) { + throw apiError("Password must be 10–128 characters."); + } + return password; +} + +function passwordDigest(password, salt) { + return scryptSync(password, salt, 64).toString("hex"); +} + +function verifyPassword(password, account) { + const actual = Buffer.from(passwordDigest(password, account.passwordSalt), "hex"); + const expected = Buffer.from(account.passwordHash, "hex"); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function tokenHash(token) { + return createHash("sha256").update(token).digest("hex"); +} + +function bearerToken(request) { + const authorization = String(request.headers.authorization ?? ""); + return authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : ""; +} + +function createSession(database, accountId) { + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS).toISOString(); + database.prepare(` + INSERT INTO sessions (account_id, token_hash, expires_at) + VALUES (?, ?, ?) + `).run(accountId, tokenHash(token), expiresAt); + return token; +} + +function currentSession(database, request) { + const token = bearerToken(request); + if (!token) return null; + return database.prepare(` + SELECT accounts.id AS accountId, accounts.username + FROM sessions + JOIN accounts ON accounts.id = sessions.account_id + WHERE sessions.token_hash = ? AND sessions.expires_at > CURRENT_TIMESTAMP + `).get(tokenHash(token)) ?? null; +} + +function requireSession(database, request) { + const session = currentSession(database, request); + if (!session) throw apiError("Sign in required.", 401); + return session; +} + +function clientAddress(request) { + return request.socket?.remoteAddress ?? "unknown"; +} + +function enforceAuthRateLimit(request) { + const now = Date.now(); + const key = clientAddress(request); + const existing = authAttempts.get(key); + const bucket = existing && now - existing.startedAt < AUTH_WINDOW_MS + ? existing + : { startedAt: now, count: 0 }; + bucket.count += 1; + authAttempts.set(key, bucket); + if (bucket.count > AUTH_ATTEMPTS_PER_WINDOW) { + throw apiError("Too many authentication attempts. Try again later.", 429); + } +} + +function register(database, payload) { + const username = validateUsername(payload?.username); + const password = validatePassword(payload?.password); + const canonical = canonicalUsername(username); + if (database.prepare("SELECT id FROM accounts WHERE canonical_username = ?").get(canonical)) { + throw apiError("Account already exists.", 409); + } + const salt = randomBytes(16).toString("hex"); + const result = database.prepare(` + INSERT INTO accounts (username, canonical_username, password_hash, password_salt) + VALUES (?, ?, ?, ?) + `).run(username, canonical, passwordDigest(password, salt), salt); + const accountId = Number(result.lastInsertRowid); + return { account: { id: accountId, username }, token: createSession(database, accountId) }; +} + +function login(database, payload) { + const canonical = canonicalUsername(payload?.username); + const password = String(payload?.password ?? ""); + const account = database.prepare(` + SELECT id, username, password_hash AS passwordHash, password_salt AS passwordSalt + FROM accounts WHERE canonical_username = ? + `).get(canonical); + if (!account || !verifyPassword(password, account)) { + throw apiError("Username or password is incorrect.", 401); + } + return { + account: { id: account.id, username: account.username }, + token: createSession(database, account.id), + }; +} + +function validateSlotId(value) { + const slotId = Number(value); + if (!Number.isInteger(slotId) || slotId < 1 || slotId > 3) throw apiError("Invalid save slot."); + return slotId; +} + +function validateSave(value, slotId) { + if (!value || typeof value !== "object" || Number(value.schemaVersion) !== 5) { + throw apiError("Save snapshot is invalid."); + } + if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request."); + if (typeof value.hunterName !== "string" || !value.hunterName.trim()) { + throw apiError("Save snapshot has no hunter name."); + } + return value; +} + +function normalizeNonNegativeInteger(value) { + const number = Math.floor(Number(value)); + return Number.isFinite(number) ? Math.max(0, number) : 0; +} + +function syncLeaderboardStats(database, accountId, slotId, save) { + database.prepare("DELETE FROM boss_kill_records WHERE account_id = ? AND slot_id = ?").run(accountId, slotId); + const insertBoss = database.prepare(` + INSERT INTO boss_kill_records (account_id, slot_id, boss_id, kills, updated_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + `); + const bossKills = save.stats?.bossKills && typeof save.stats.bossKills === "object" + ? save.stats.bossKills + : {}; + for (const [bossId, rawKills] of Object.entries(bossKills)) { + if (!/^[a-z0-9-]{1,64}$/.test(bossId)) continue; + const kills = normalizeNonNegativeInteger(rawKills); + if (kills > 0) insertBoss.run(accountId, slotId, bossId, kills); + } + const highestRound = normalizeNonNegativeInteger(save.stats?.highestRoguelikeRound); + database.prepare(` + INSERT INTO roguelike_records (account_id, slot_id, highest_round, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(account_id, slot_id) DO UPDATE SET + highest_round = excluded.highest_round, + updated_at = CURRENT_TIMESTAMP + `).run(accountId, slotId, highestRound); +} + +function writeSave(database, accountId, slotId, rawSave) { + const save = validateSave(rawSave, slotId); + const serialized = JSON.stringify(save); + if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413); + database.exec("BEGIN IMMEDIATE"); + try { + database.prepare(` + INSERT INTO hunter_saves (account_id, slot_id, hunter_name, save_json, updated_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(account_id, slot_id) DO UPDATE SET + hunter_name = excluded.hunter_name, + save_json = excluded.save_json, + updated_at = CURRENT_TIMESTAMP + `).run(accountId, slotId, save.hunterName.trim().slice(0, 20), serialized); + syncLeaderboardStats(database, accountId, slotId, save); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + return save; +} + +function readSave(database, accountId, slotId) { + const row = database.prepare(` + SELECT save_json AS saveJson FROM hunter_saves WHERE account_id = ? AND slot_id = ? + `).get(accountId, slotId); + if (!row) return null; + try { return JSON.parse(row.saveJson); } catch { return null; } +} + +function listSaves(database, accountId) { + return database.prepare(` + SELECT slot_id AS slotId, save_json AS saveJson, updated_at AS updatedAt + FROM hunter_saves WHERE account_id = ? ORDER BY slot_id + `).all(accountId).flatMap((row) => { + try { return [{ slotId: row.slotId, save: JSON.parse(row.saveJson), updatedAt: row.updatedAt }]; } + catch { return []; } + }); +} + +function leaderboardEntry(row, valueKey) { + return { + rank: row.rank, + username: row.username, + hunterName: row.hunterName, + slotId: row.slotId, + value: row[valueKey], + }; +} + +function bossLeaderboard(database, accountId, slotId, bossId) { + if (!/^[a-z0-9-]{1,64}$/.test(bossId)) throw apiError("Invalid boss."); + const rows = database.prepare(` + WITH ranked AS ( + SELECT + RANK() OVER (ORDER BY records.kills DESC) AS rank, + records.account_id AS accountId, + records.slot_id AS slotId, + records.kills, + accounts.username, + saves.hunter_name AS hunterName, + records.updated_at AS updatedAt + FROM boss_kill_records records + JOIN accounts ON accounts.id = records.account_id + JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id + WHERE records.boss_id = ? + ) + SELECT * FROM ranked ORDER BY kills DESC, updatedAt ASC, accountId ASC, slotId ASC + `).all(bossId); + const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; + return { + kind: "boss", + bossId, + top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "kills")), + current: current ? leaderboardEntry(current, "kills") : null, + }; +} + +function roguelikeLeaderboard(database, accountId, slotId) { + const rows = database.prepare(` + WITH ranked AS ( + SELECT + RANK() OVER (ORDER BY records.highest_round DESC) AS rank, + records.account_id AS accountId, + records.slot_id AS slotId, + records.highest_round AS highestRound, + accounts.username, + saves.hunter_name AS hunterName, + records.updated_at AS updatedAt + FROM roguelike_records records + JOIN accounts ON accounts.id = records.account_id + JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id + WHERE records.highest_round > 0 + ) + SELECT * FROM ranked ORDER BY highestRound DESC, updatedAt ASC, accountId ASC, slotId ASC + `).all(); + const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; + return { + kind: "roguelike", + top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestRound")), + current: current ? leaderboardEntry(current, "highestRound") : null, + }; +} + +export function createGameApiHandler(options = {}) { + const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data"); + mkdirSync(dataDirectory, { recursive: true }); + const database = new DatabaseSync(resolve(dataDirectory, "game.db")); + database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8")); + + async function handle(request, response, next) { + if (!request.url?.startsWith("/api/")) return next(); + setCorsHeaders(request, response); + if (request.method === "OPTIONS") { + response.statusCode = 204; + return response.end(); + } + try { + database.prepare("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP").run(); + const url = new URL(request.url, "http://localhost"); + const path = url.pathname; + + if (path === "/api/health" && request.method === "GET") { + return sendJson(response, 200, { ok: true, database: "ready" }); + } + if (path === "/api/auth/register" && request.method === "POST") { + enforceAuthRateLimit(request); + return sendJson(response, 201, register(database, await readJson(request))); + } + if (path === "/api/auth/login" && request.method === "POST") { + enforceAuthRateLimit(request); + return sendJson(response, 200, login(database, await readJson(request))); + } + if (path === "/api/auth/session" && request.method === "GET") { + const session = currentSession(database, request); + return sendJson(response, session ? 200 : 401, session + ? { account: { id: session.accountId, username: session.username } } + : { error: "Sign in required." }); + } + if (path === "/api/auth/logout" && request.method === "POST") { + const token = bearerToken(request); + if (token) database.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash(token)); + return sendJson(response, 200, { ok: true }); + } + + const session = requireSession(database, request); + if (path === "/api/saves" && request.method === "GET") { + return sendJson(response, 200, { slots: listSaves(database, session.accountId) }); + } + const saveMatch = path.match(/^\/api\/saves\/([1-3])$/); + if (saveMatch && request.method === "GET") { + return sendJson(response, 200, { save: readSave(database, session.accountId, validateSlotId(saveMatch[1])) }); + } + if (saveMatch && request.method === "PUT") { + const slotId = validateSlotId(saveMatch[1]); + const payload = await readJson(request); + return sendJson(response, 200, { save: writeSave(database, session.accountId, slotId, payload?.save) }); + } + const bossMatch = path.match(/^\/api\/leaderboards\/boss\/([a-z0-9-]+)$/); + if (bossMatch && request.method === "GET") { + const slotId = validateSlotId(url.searchParams.get("slot")); + return sendJson(response, 200, bossLeaderboard(database, session.accountId, slotId, bossMatch[1])); + } + if (path === "/api/leaderboards/roguelike" && request.method === "GET") { + const slotId = validateSlotId(url.searchParams.get("slot")); + return sendJson(response, 200, roguelikeLeaderboard(database, session.accountId, slotId)); + } + return sendJson(response, 404, { error: "API route not found." }); + } catch (error) { + const status = Number(error?.status) || 500; + const message = status >= 500 ? "Server error." : error.message; + if (status >= 500) console.error(error); + return sendJson(response, status, { error: message }); + } + } + + return { handle, close: () => database.close() }; +} diff --git a/server/game-api.test.mjs b/server/game-api.test.mjs new file mode 100644 index 0000000..613abcf --- /dev/null +++ b/server/game-api.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createGameApiHandler } from "./game-api.mjs"; + +const dataDirectory = mkdtempSync(join(tmpdir(), "iwt-heal-api-")); +const api = createGameApiHandler({ dataDirectory }); +const server = createServer((request, response) => { + void api.handle(request, response, () => { + response.statusCode = 404; + response.end(); + }); +}); +let baseUrl; + +before(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +after(async () => { + await new Promise((resolve) => server.close(resolve)); + api.close(); + rmSync(dataDirectory, { recursive: true, force: true }); +}); + +async function json(path, init = {}) { + const response = await fetch(`${baseUrl}${path}`, init); + const body = await response.json(); + return { response, body }; +} + +function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound) { + return { + schemaVersion: 5, + slotId, + hunterName, + stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound }, + }; +} + +test("health endpoint reports persistent database readiness", async () => { + const { response, body } = await json("/api/health"); + assert.equal(response.status, 200); + assert.deepEqual(body, { ok: true, database: "ready" }); +}); + +test("accounts, server saves, and top-five plus current rankings work end to end", async () => { + const players = []; + for (let index = 0; index < 6; index += 1) { + const username = `hunter_${index}`; + const registration = await json("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password: `long-password-${index}` }), + }); + assert.equal(registration.response.status, 201); + const token = registration.body.token; + const kills = 60 - index * 10; + const highestRound = 30 - index * 4; + const upload = await json("/api/saves/1", { + method: "PUT", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound) }), + }); + assert.equal(upload.response.status, 200); + players.push({ token, kills, highestRound }); + } + + const current = players[5]; + const bossBoard = await json("/api/leaderboards/boss/bulldrome?slot=1", { + headers: { Authorization: `Bearer ${current.token}` }, + }); + assert.equal(bossBoard.body.top.length, 5); + assert.equal(bossBoard.body.top[0].value, 60); + assert.equal(bossBoard.body.current.rank, 6); + assert.equal(bossBoard.body.current.value, current.kills); + + const rogueBoard = await json("/api/leaderboards/roguelike?slot=1", { + headers: { Authorization: `Bearer ${current.token}` }, + }); + assert.equal(rogueBoard.body.top.length, 5); + assert.equal(rogueBoard.body.current.rank, 6); + assert.equal(rogueBoard.body.current.value, current.highestRound); + + const download = await json("/api/saves/1", { + headers: { Authorization: `Bearer ${current.token}` }, + }); + assert.equal(download.body.save.hunterName, "Hero 5"); +}); + +test("invalid credentials cannot access server saves", async () => { + const login = await json("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "hunter_0", password: "incorrect-password" }), + }); + assert.equal(login.response.status, 401); + const saves = await json("/api/saves"); + assert.equal(saves.response.status, 401); +}); diff --git a/server/production.mjs b/server/production.mjs new file mode 100644 index 0000000..1cc4ea3 --- /dev/null +++ b/server/production.mjs @@ -0,0 +1,62 @@ +import { createReadStream, existsSync, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createGameApiHandler } from "./game-api.mjs"; + +const distPath = fileURLToPath(new URL("../dist", import.meta.url)); +const indexPath = resolve(distPath, "index.html"); +const host = process.env.HOST ?? "127.0.0.1"; +const port = Number(process.env.PORT ?? 4173); +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".glb": "model/gltf-binary", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +}; + +function sendFile(response, filePath) { + response.statusCode = 200; + response.setHeader("Content-Type", contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("Referrer-Policy", "same-origin"); + response.setHeader("X-Frame-Options", "DENY"); + createReadStream(filePath).pipe(response); +} + +function serveStatic(request, response) { + let requestPath; + try { requestPath = decodeURIComponent(new URL(request.url, "http://localhost").pathname); } + catch { response.statusCode = 400; return response.end("Bad request"); } + const candidate = resolve(distPath, `.${requestPath}`); + const insideDist = candidate === distPath || candidate.startsWith(`${distPath}${sep}`); + if (insideDist && existsSync(candidate) && statSync(candidate).isFile()) return sendFile(response, candidate); + if (!existsSync(indexPath)) { + response.statusCode = 503; + return response.end("Build missing. Run pnpm build."); + } + return sendFile(response, indexPath); +} + +const api = createGameApiHandler(); +const server = createServer((request, response) => { + void api.handle(request, response, () => serveStatic(request, response)); +}); + +server.listen(port, host, () => console.log(`I Want To Heal listening on http://${host}:${port}`)); + +function shutdown() { + server.close(() => { + api.close(); + process.exit(0); + }); +} +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/src/App.tsx b/src/App.tsx index 7677e67..078fe62 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,20 +32,24 @@ export default function App() { const touchActiveSave = useFrontendStore((state) => state.touchActiveSave); const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory); const recordBossVictory = useFrontendStore((state) => state.recordBossVictory); + const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat); const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards); const rewardedBossInstances = useRef(new Set()); const screenRef = useRef(screen); screenRef.current = screen; const leaveGame = useCallback(() => { + const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState(); updateActiveHealerInventory(useGameStore.getState().inventory); touchActiveSave(); navigate("home"); + if (accountId && activeSlotId) void uploadSlot(activeSlotId); }, [navigate, touchActiveSave, updateActiveHealerInventory]); const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => { if (!hunter) return; const progress = hunter.healers[hunter.activeClassId]; - const runMode = useFrontendStore.getState().selectedMode === "roguelike-pve" ? "roguelike" : "encounter"; - const launchDifficulty = runMode === "roguelike" + const selectedMode = useFrontendStore.getState().selectedMode; + const runMode = selectedMode === "roguelike-pve" ? "roguelike" : selectedMode === "rogue-trials" ? "rogue-trials" : "encounter"; + const launchDifficulty = runMode !== "encounter" ? "initiate" : requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug; rewardedBossInstances.current.clear(); @@ -81,13 +85,16 @@ export default function App() { } if (startedFreshEncounter) clearRecentRewards(); if (screenRef.current !== "game") return; + if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") { + recordRoguelikeDefeat(state.round); + } const bossCount = 1 + state.additionalBosses.length; if (state.boss.hp <= 0 && previousState.boss.hp > 0) { const primaryInstanceId = `boss-0-${state.boss.id}`; if (!rewardedBossInstances.current.has(primaryInstanceId)) { rewardedBossInstances.current.add(primaryInstanceId); const defeatedBefore = (state.round - 1) * bossCount; - const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug; + const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug; recordBossVictory(state.boss.id, rewardDifficulty); } } @@ -98,15 +105,15 @@ export default function App() { if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue; rewardedBossInstances.current.add(entry.instanceId); const defeatedBefore = (state.round - 1) * bossCount + index + 1; - const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug; + const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug; recordBossVictory(entry.boss.id, rewardDifficulty); } }); - }, [clearRecentRewards, recordBossVictory]); + }, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat]); return ( -
-
+
+
THOR / DUAL DISPLAYI Want To Heal

Offline-first healer roguelike v{packageJson.version}

diff --git a/src/components/BossTrophyPortrait.tsx b/src/components/BossTrophyPortrait.tsx new file mode 100644 index 0000000..ea41e71 --- /dev/null +++ b/src/components/BossTrophyPortrait.tsx @@ -0,0 +1,57 @@ +import { Canvas } from "@react-three/fiber"; +import { useGLTF } from "@react-three/drei"; +import { Suspense, useMemo } from "react"; +import * as THREE from "three"; +import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; +import { BOSS_DEFINITIONS } from "../game/bossCatalog"; +import { ALTERNATE_BOSS_CONFIG, bossVisualUrl } from "../game/bossVisuals"; +import type { BossId } from "../game/types"; + +function PortraitModel({ bossId }: { bossId: BossId }) { + const gltf = useGLTF(bossVisualUrl(bossId), false, true); + const model = useMemo(() => { + const clone = cloneSkeleton(gltf.scene); + clone.updateMatrixWorld(true); + const bounds = new THREE.Box3().setFromObject(clone); + const center = bounds.getCenter(new THREE.Vector3()); + const size = bounds.getSize(new THREE.Vector3()); + const scale = 2.35 / Math.max(size.x, size.y, size.z, 0.001); + clone.traverse((object) => { + if (object instanceof THREE.Mesh) { + object.castShadow = false; + object.receiveShadow = false; + } + }); + return { clone, center, scale }; + }, [gltf.scene]); + + return ( + + ); +} + +export function BossTrophyPortrait({ bossId }: { bossId: BossId }) { + const boss = BOSS_DEFINITIONS[bossId]; + return ( +
+ + + + + + + +
+ ); +} diff --git a/src/components/BuffDraftPanel.tsx b/src/components/BuffDraftPanel.tsx index 601e577..9be8443 100644 --- a/src/components/BuffDraftPanel.tsx +++ b/src/components/BuffDraftPanel.tsx @@ -1,25 +1,39 @@ -import { RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike"; +import { useEffect, useState } from "react"; +import { ROGUE_TRIALS_TRIO_ROUND, RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike"; import { HEALER_CLASSES } from "../game/healers"; -import { useGameStore } from "../game/store"; +import { isRunBuffInputLocked, useGameStore } from "../game/store"; export function BuffDraftPanel({ className = "" }: { className?: string }) { const round = useGameStore((state) => state.round); + const runMode = useGameStore((state) => state.runMode); const healerClassId = useGameStore((state) => state.healerClassId); const runBuffRanks = useGameStore((state) => state.runBuffRanks); const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId); const choices = useGameStore((state) => state.draftBuffIds); const selected = useGameStore((state) => state.selectedRunBuffId); + const inputUnlockAt = useGameStore((state) => state.runBuffInputUnlockAt); const setSelected = useGameStore((state) => state.setSelectedRunBuff); const choose = useGameStore((state) => state.chooseRunBuff); const continueRun = useGameStore((state) => state.continueRoguelikeRound); const nextRound = round + 1; + const nextBossCount = runMode === "rogue-trials" && nextRound === ROGUE_TRIALS_TRIO_ROUND ? 3 : 2; const abilities = HEALER_CLASSES[healerClassId].abilities; + const [inputLocked, setInputLocked] = useState(() => isRunBuffInputLocked(useGameStore.getState())); + + useEffect(() => { + const remaining = inputUnlockAt - Date.now(); + setInputLocked(remaining > 0); + if (remaining <= 0) return; + const timer = window.setTimeout(() => setInputLocked(false), remaining); + return () => window.clearTimeout(timer); + }, [inputUnlockAt]); + return ( -
+
Round {round} cleared

Choose one blessing

-

Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.

+

Claim required. Round {nextRound} begins with {nextBossCount === 3 ? "an unseen trio" : "two new bosses"} at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.

{choices.length > 0 ? choices.map((buffId) => { @@ -35,6 +49,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) { onFocus={() => setSelected(buffId)} onPointerEnter={() => setSelected(buffId)} onClick={() => choose(buffId)} + disabled={inputLocked} aria-pressed={selected === buffId} > {buff.icon} @@ -44,7 +59,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) { ); }) : ( - )}
-
{choices.length > 0 && <>← / → Choose } A / ENTER {choices.length > 0 ? "Claim" : "Continue"}
+
{inputLocked ? Choices ready in a moment… : <>{choices.length > 0 && <>← / → Choose } A / ENTER {choices.length > 0 ? "Claim" : "Continue"}}
); } diff --git a/src/components/FrontEnd.tsx b/src/components/FrontEnd.tsx index a9a2061..1c8e0d9 100644 --- a/src/components/FrontEnd.tsx +++ b/src/components/FrontEnd.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data"; import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository"; import { useActiveHunter, useFrontendStore } from "../frontend/store"; @@ -19,8 +19,10 @@ import { GEAR_STAT_LABELS, MAX_GEAR_LEVEL, canAffordGearUpgrade, + canUpgradeGearSlot, gearBonusText, gearUpgradeCosts, + type GearOwnerId, } from "../game/progression/gear"; import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot"; import { @@ -33,8 +35,11 @@ import { passiveInfusionUnlocked, } from "../game/progression/infusions"; import { requestDisplaySurface } from "../platform/displayRouting"; +import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository"; import { DualDisplayFrame } from "./DualDisplayFrame"; +const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait }))); + function FocusButton({ id, focusedId, @@ -75,6 +80,7 @@ function ControllerLegend({ back = false }: { back?: boolean }) { } function LoginScreen() { + const restoreSession = useFrontendStore((state) => state.restoreSession); const signIn = useFrontendStore((state) => state.signIn); const createAccount = useFrontendStore((state) => state.createAccount); const continueOffline = useFrontendStore((state) => state.continueOffline); @@ -83,6 +89,12 @@ function LoginScreen() { const [password, setPassword] = useState(""); const usernameRef = useRef(null); const passwordRef = useRef(null); + const restoreStarted = useRef(false); + useEffect(() => { + if (restoreStarted.current) return; + restoreStarted.current = true; + void restoreSession(); + }, [restoreSession]); const actions = useMemo(() => [ { id: "username", run: () => usernameRef.current?.focus() }, { id: "password", run: () => passwordRef.current?.focus() }, @@ -115,6 +127,9 @@ function LoginScreen() { onChange={(event) => setUsername(event.target.value)} onFocus={() => controller.focus("username")} autoComplete="username" + maxLength={20} + minLength={3} + pattern="[A-Za-z0-9_]+" required /> @@ -127,6 +142,8 @@ function LoginScreen() { onChange={(event) => setPassword(event.target.value)} onFocus={() => controller.focus("password")} autoComplete="current-password" + maxLength={128} + minLength={10} required /> @@ -150,8 +167,8 @@ function LoginScreen() { How saving works
  1. 01Play offlineEvery change writes to device storage first.
  2. -
  3. 02Create or sign inUsername and password unlock online sync.
  4. -
  5. 03Move devicesSign in, then upload or download an online copy.
  6. +
  7. 02Create or sign inAccount is secured by the TrueNAS game server.
  8. +
  9. 03Move devicesUpload or download any of your three server save slots.
PCONLINE COPYTHOR
@@ -339,6 +356,7 @@ function SaveScreen() { const HOME_MODES: { id: GameModeId; icon: string; label: string; copy: string }[] = [ { id: "roguelike-pve", icon: "✦", label: "PVE", copy: "Randomized roguelike runs" }, + { id: "rogue-trials", icon: "Ⅲ", label: "Rogue Trials", copy: "Four rounds, then a boss trio" }, { id: "dungeons", icon: "♜", label: "Dungeons", copy: "Choose your boss encounter" }, { id: "roguelike-pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" }, { id: "stadium-pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" }, @@ -351,10 +369,11 @@ function HomeScreen() { const selectHealerClass = useFrontendStore((state) => state.selectHealerClass); const navigate = useFrontendStore((state) => state.navigate); const actions = useMemo(() => [ - { id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "dungeons", down: "roguelike-pvp" } }, - { id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "roguelike-pve", down: "stadium-pvp" } }, + { id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "rogue-trials", down: "roguelike-pvp" } }, + { id: "rogue-trials", run: () => selectMode("rogue-trials"), neighbors: { left: "roguelike-pve", right: "dungeons", down: "stadium-pvp" } }, + { id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "rogue-trials", down: "stadium-pvp" } }, { id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { left: "roguelike-pve", right: "stadium-pvp", up: "roguelike-pve", down: "profile" } }, - { id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", up: "dungeons", down: "settings" } }, + { id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", up: "rogue-trials", down: "settings" } }, { id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "gear", down: "class-priest" } }, { id: "gear", run: () => navigate("gear"), neighbors: { up: "roguelike-pvp", left: "profile", right: "settings", down: "class-druid" } }, { id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "gear", down: "class-shaman" } }, @@ -424,37 +443,119 @@ function HomeScreen() { function ProfileScreen() { const hunter = useActiveHunter(); + const accountId = useFrontendStore((state) => state.accountId); const navigate = useFrontendStore((state) => state.navigate); const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]); const [groupId, setGroupId] = useState(collections[0]?.groupId ?? ""); + const [collectionView, setCollectionView] = useState<"loot" | "trophies" | "stats">("trophies"); const collection = collections.find((group) => group.groupId === groupId) ?? collections[0]; + const [selectedStat, setSelectedStat] = useState("roguelike"); + const [leaderboard, setLeaderboard] = useState(null); + const [leaderboardStatus, setLeaderboardStatus] = useState(""); + useEffect(() => { + if (selectedStat === "roguelike" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return; + setSelectedStat(collection?.bosses[0]?.bossId ?? "roguelike"); + }, [collection, selectedStat]); + useEffect(() => { + if (!hunter || collectionView !== "stats") return; + if (!accountId) { + setLeaderboard(null); + setLeaderboardStatus("Sign in to view overall rankings."); + return; + } + let cancelled = false; + setLeaderboardStatus("Loading overall rankings…"); + const request = selectedStat === "roguelike" + ? onlineRepository.roguelikeLeaderboard(hunter.slotId) + : onlineRepository.bossLeaderboard(selectedStat, hunter.slotId); + void request.then((result) => { + if (cancelled) return; + setLeaderboard(result); + setLeaderboardStatus(""); + }).catch((error) => { + if (cancelled) return; + setLeaderboard(null); + setLeaderboardStatus(error instanceof Error ? error.message : "Leaderboard unavailable."); + }); + return () => { cancelled = true; }; + }, [accountId, collectionView, hunter, selectedStat]); const actions = useMemo(() => [ + { id: "view-trophies", run: () => setCollectionView("trophies"), neighbors: { right: "view-stats" } }, + { id: "view-stats", run: () => setCollectionView("stats"), neighbors: { left: "view-trophies", right: "view-loot", down: collectionView === "stats" ? "stat-roguelike" : undefined } }, + { id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } }, + ...(collectionView === "stats" ? [ + { id: "stat-roguelike", run: () => setSelectedStat("roguelike"), neighbors: { up: "view-stats", down: `stat-${collection.bosses[0].bossId}` } }, + ...collection.bosses.map((boss, index) => ({ + id: `stat-${boss.bossId}`, + run: () => setSelectedStat(boss.bossId), + neighbors: { + up: index === 0 ? "stat-roguelike" : `stat-${collection.bosses[index - 1].bossId}`, + down: index === collection.bosses.length - 1 ? `group-${collection.groupId}` : `stat-${collection.bosses[index + 1].bossId}`, + }, + })), + ] : []), ...collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })), { id: "back", run: () => navigate("home") }, - ], [collections, navigate]); + ], [collection, collectionView, collections, navigate]); const controller = useMenuController(actions, { onBack: () => navigate("home") }); if (!hunter || !collection) return null; const activeHealer = HEALER_CLASSES[hunter.activeClassId]; const activeProgress = hunter.healers[hunter.activeClassId]; const earned = collection.drops.filter((drop) => drop.count > 0).length; + const trophiesEarned = collection.bosses.filter((boss) => boss.pet.count > 0).length; return ( -
Hunter profile

Collection log

navigate("home")}>B · Back
-
Shared group drops · Core: {collection.coreMechanic}

Group {collection.groupLetter} · {collection.groupName}

{earned} / {collection.drops.length} discovered
-
- {collection.drops.map((drop) => ( -
- {drop.icon}{drop.count} - {drop.rarity}{drop.name} -

{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}

- {drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""} -
- ))} -
-
Boss pets stay individual.{collection.bosses.map((boss) => `${boss.bossName}: ${boss.kills} kills · ${boss.pet.count} pets`).join(" · ")}
+
Hunter profile

Collection log

+ setCollectionView("trophies")}>Trophy Case + setCollectionView("stats")}>Boss Stats + setCollectionView("loot")}>Group Loot +
navigate("home")}>B · Back
+ {collectionView === "loot" ? <> +
Shared group drops · Core: {collection.coreMechanic}

Group {collection.groupLetter} · {collection.groupName}

{earned} / {collection.drops.length} discovered
+
+ {collection.drops.map((drop) => ( +
+ {drop.icon}{drop.count} + {drop.rarity}{drop.name} +

{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}

+ {drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""} +
+ ))} +
+
Boss pets stay individual.Open Trophy Case to inspect every guardian pet.
+ : collectionView === "trophies" ? <> +
Boss pets · 1 in 500 per victory

Group {collection.groupLetter} · {collection.groupName}

{trophiesEarned} / {collection.bosses.length} trophies lit
+
+ {collection.bosses.map((boss) => { + const owned = boss.pet.count > 0; + return
+ {BOSS_DEFINITIONS[boss.bossId].icon}
}> +
{owned ? "Pet secured" : "Pet undiscovered"}{boss.bossName}{boss.kills} kills · {boss.pet.chance}
+ {owned ? `Owned${boss.pet.count > 1 ? ` ×${boss.pet.count}` : ""}` : "Locked"} + ; + })} + +
Each guardian keeps its own trophy.Defeat that boss for a 1 in 500 pet roll.
+ : <> +
Lifetime records · Overall leaderboards

Boss Stats

Highest roguelike round {hunter.stats.highestRoguelikeRound}
+
+
+ setSelectedStat("roguelike")}>RoguelikeHighest round before defeat{hunter.stats.highestRoguelikeRound} + {collection.bosses.map((boss) => setSelectedStat(boss.bossId)}>{BOSS_DEFINITIONS[boss.bossId].icon}{boss.bossName}Lifetime boss kills{boss.kills})} +
+
+
Overall Top 5{selectedStat === "roguelike" ? "Roguelike rounds" : BOSS_DEFINITIONS[selectedStat].name}{selectedStat === "roguelike" ? `${hunter.stats.highestRoguelikeRound} best` : `${hunter.stats.bossKills[selectedStat] ?? 0} kills`}
+ {leaderboardStatus ?
{leaderboardStatus}
:
+ {leaderboard?.top.length ? leaderboard.top.map((entry) =>
#{entry.rank}{entry.hunterName}{entry.username}{entry.value}
) :
No ranked hunters yet.
} +
} +
{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}Your rank · {hunter.hunterName}{accountId ?? "Offline hunter"}{selectedStat === "roguelike" ? hunter.stats.highestRoguelikeRound : hunter.stats.bossKills[selectedStat] ?? 0}
+
+
+
Rankings update with server saves.Top five always shown; your row stays visible at any rank.
+ } } bottom={ @@ -465,6 +566,7 @@ function ProfileScreen() { Flawless clears{hunter.stats.flawlessClears} Allies saved{hunter.stats.alliesSaved} Healing done{hunter.stats.healingDone.toLocaleString()} + Highest roguelike round{hunter.stats.highestRoguelikeRound}
Mechanic groups{collections.map((group) => ( setGroupId(group.groupId)}> @@ -497,9 +599,23 @@ function GearScreen() { const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion); const installPassive = useFrontendStore((state) => state.equipPassiveInfusion); const slot = hunter?.gearProgress[selectedOwnerId].slots[selectedSlotId]; + const upgradeReadiness = useMemo(() => { + const owners = new Set(); + const slots = new Set(); + if (!hunter) return { owners, slots }; + + for (const ownerId of GEAR_OWNER_ORDER) { + for (const slotId of GEAR_SLOT_ORDER) { + if (!canUpgradeGearSlot(hunter.gearProgress, hunter.materials, ownerId, slotId)) continue; + owners.add(ownerId); + slots.add(`${ownerId}:${slotId}`); + } + } + return { owners, slots }; + }, [hunter]); const recipe = GEAR_RECIPES[selectedOwnerId][selectedSlotId]; const costs = hunter && slot ? gearUpgradeCosts(selectedOwnerId, selectedSlotId, slot.level) : []; - const canUpgrade = Boolean(hunter && slot && slot.level < MAX_GEAR_LEVEL && canAffordGearUpgrade(hunter.materials, costs)); + const canUpgrade = upgradeReadiness.slots.has(`${selectedOwnerId}:${selectedSlotId}`); const infusionChoices = infusionsForOwner(selectedOwnerId); const selectedInfusion = infusionChoices.find((choice) => choice.id === selectedInfusionId) ?? infusionChoices[0]; const selectedInfusionCosts = hunter ? infusionCosts(selectedOwnerId, selectedSlotId, selectedInfusion.id) : []; @@ -590,14 +706,16 @@ function GearScreen() {
{GEAR_OWNER_ORDER.map((ownerId) => { const highest = Math.max(...GEAR_SLOT_ORDER.map((slotId) => hunter.gearProgress[ownerId].slots[slotId].level)); - return selectOwner(ownerId)}>{GEAR_OWNER_LABELS[ownerId]}Highest slot +{highest}{ownerId === selectedOwnerId ? "✓" : ""}; + const upgradeReady = upgradeReadiness.owners.has(ownerId); + return selectOwner(ownerId)}>{GEAR_OWNER_LABELS[ownerId]}Highest slot +{highest}{ownerId === selectedOwnerId ? "✓" : ""}; })}
{GEAR_SLOT_ORDER.map((slotId) => { const progress = hunter.gearProgress[selectedOwnerId].slots[slotId]; const slotRecipe = GEAR_RECIPES[selectedOwnerId][slotId]; - return selectSlot(slotId)}>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}{GEAR_SLOT_LABELS[slotId]}{GEAR_STAT_LABELS[slotRecipe.statId]}+{progress.level}; + const upgradeReady = upgradeReadiness.slots.has(`${selectedOwnerId}:${slotId}`); + return selectSlot(slotId)}>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}{GEAR_SLOT_LABELS[slotId]}{GEAR_STAT_LABELS[slotRecipe.statId]}+{progress.level}; })}
{workshopMode === "upgrade" ?
@@ -692,7 +810,7 @@ function SettingsScreen() {
YXBA
-
A Confirm / cast PurifyB Back / cast ShieldD-Pad Navigate / target partyStart Pause / menu
+
A Confirm / cast PurifyB Back / cast ShieldD-Pad Navigate / target partyRight stick Rotate cameraStart Pause / menu
No click-to-focus requiredController input routes through app-level actions.
} @@ -715,6 +833,8 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi const selectedBoss = BOSS_DEFINITIONS[selectedBossId]; const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug]; const isPve = modeId === "roguelike-pve"; + const isRogueTrials = modeId === "rogue-trials"; + const isPveRun = isPve || isRogueTrials; const isDungeon = modeId === "dungeons"; const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId]; const visibleBossIds = selectedBossGroup.bossIds; @@ -723,9 +843,9 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]); }; const launch = () => { - if (isPve) return onLaunch(selectRandomBossPair(), "initiate"); + if (isPveRun) return onLaunch(selectRandomBossPair(), "initiate"); if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug); - setMessage("Online matchmaking connects here when game server is configured."); + setMessage("Online matchmaking is not available for this mode yet."); }; const actions = useMemo(() => [ ...(isDungeon ? BOSS_GROUPS.map((group, index) => { @@ -775,15 +895,21 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi })) : []), { id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } }, { id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } }, - ], [bossGridColumns, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]); + ], [bossGridColumns, isDungeon, isPveRun, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]); const controller = useMenuController(actions, { onBack: () => navigate("home") }); - const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking"; + const launchLabel = isRogueTrials ? "Begin Rogue Trials" : isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking"; const contextRules = isDungeon ? [ [selectedBoss.name, selectedBoss.summary], [bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing], [bossMechanicName(selectedBoss.mechanicIds[1]), "Controller-ready party behavior and full lower-display support."], ] + : isRogueTrials + ? [ + ["Four dual rounds", "Clear four randomized pairs while drafting one stacking buff after each win."], + ["Unseen trio finale", "Round 5 selects three bosses that have not appeared earlier in that run."], + ["Trial victory", "Defeat all three final bosses together to complete Rogue Trials."], + ] : isPve ? [ ["Randomized pair", "Two distinct bosses are selected only when the run begins."], diff --git a/src/components/GameScene.tsx b/src/components/GameScene.tsx index 49ea4cf..91070e6 100644 --- a/src/components/GameScene.tsx +++ b/src/components/GameScene.tsx @@ -6,7 +6,19 @@ import { getControllerMovement } from "../input/controller"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { ARENA_CENTER, clampToArena } from "../game/arena"; import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog"; +import { ALTERNATE_BOSS_CONFIG, BULL_URL, type AlternateBossKind } from "../game/bossVisuals"; import { bossAnimationCue } from "../game/bosses/mechanicPool"; +import { + CAMERA_FOCUS_HEIGHT, + CAMERA_LOOK_AHEAD, + CAMERA_ORBIT_DISTANCE, + DEFAULT_CAMERA_PITCH, + DEFAULT_CAMERA_YAW, + setCameraRelativeMovement, + updateCameraOrbit, + type CameraOrbitState, + type PlanarMovement, +} from "../game/cameraOrbit"; import { isActorAnimationOneShot, shouldStartActorAnimation, @@ -17,43 +29,8 @@ import { useGameStore } from "../game/store"; import type { BossId, MemberId, PulseKind } from "../game/types"; import { BossRoom } from "./BossRoom"; import { BossMechanicIndicators } from "./boss/BossMechanicIndicators"; +import { bossCanTrackTarget } from "./boss/bossDeathVisuals"; -const BULL_URL = new URL("../assets/game/models/claudecraft/creatures/bull.glb", import.meta.url).href; -const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href; -const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href; -const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href; -const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href; -const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href; -const CLAUDE_BOSS_URLS: Record, string> = { - "stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href, - "cluckhorn-colossus": new URL("../assets/game/models/claudecraft/creatures/chicken_cow.glb", import.meta.url).href, - "ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href, - "riftclaw-demon": new URL("../assets/game/models/claudecraft/creatures/demonalt.glb", import.meta.url).href, - "tempestscale-dragon": new URL("../assets/game/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href, - emberfox: new URL("../assets/game/models/claudecraft/creatures/fox.glb", import.meta.url).href, - "mirelord-frog": new URL("../assets/game/models/claudecraft/creatures/frog.glb", import.meta.url).href, - "stonebreaker-giant": new URL("../assets/game/models/claudecraft/creatures/giant.glb", import.meta.url).href, - "glub-sovereign": new URL("../assets/game/models/claudecraft/creatures/glubevolved.glb", import.meta.url).href, - "scrapking-goblin": new URL("../assets/game/models/claudecraft/creatures/goblin.glb", import.meta.url).href, - "warcaller-orc": new URL("../assets/game/models/claudecraft/creatures/orc.glb", import.meta.url).href, - "tuskmaw-orc": new URL("../assets/game/models/claudecraft/creatures/orcenemy.glb", import.meta.url).href, - "broodfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href, - "silkfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href, - "thorncrown-stag": new URL("../assets/game/models/claudecraft/creatures/stag.glb", import.meta.url).href, - "sky-totem": new URL("../assets/game/models/claudecraft/creatures/tribal.glb", import.meta.url).href, - "razorcrest-raptor": new URL("../assets/game/models/claudecraft/creatures/velociraptor.glb", import.meta.url).href, - "bristlequake-boar": new URL("../assets/game/models/claudecraft/creatures/wild_boar.glb", import.meta.url).href, - "moonfang-wolf": new URL("../assets/game/models/claudecraft/creatures/wolf.glb", import.meta.url).href, - "frostmaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yeti.glb", import.meta.url).href, - "rimeclaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yetialt.glb", import.meta.url).href, -}; const PARTY_MODEL_URLS: Record = { aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href, brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href, @@ -428,6 +405,8 @@ function PlayerCharacter() { const castingUntil = useRef(0); const instantCastTrigger = useRef(0); const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []); + const cameraOrbit = useRef({ yaw: DEFAULT_CAMERA_YAW, pitch: DEFAULT_CAMERA_PITCH }); + const cameraRelativeMovement = useRef({ x: 0, z: 0 }); useEffect(() => { const start = useGameStore.getState().partyPositions.aelia; @@ -451,7 +430,11 @@ function PlayerCharacter() { const nudgeX = Number(key === "d") - Number(key === "a"); const nudgeZ = Number(key === "s") - Number(key === "w"); if (!nudgeX && !nudgeZ) return; - const next = clampToArena([group.current.position.x + nudgeX * 0.18, group.current.position.z + nudgeZ * 0.18]); + setCameraRelativeMovement(cameraRelativeMovement.current, nudgeX, nudgeZ, cameraOrbit.current.yaw); + const next = clampToArena([ + group.current.position.x + cameraRelativeMovement.current.x * 0.18, + group.current.position.z + cameraRelativeMovement.current.z * 0.18, + ]); group.current.position.x = next[0]; group.current.position.z = next[1]; setPlayerPosition([group.current.position.x, group.current.position.z]); @@ -476,9 +459,16 @@ function PlayerCharacter() { inputX = Number(keys.current.has("d")) - Number(keys.current.has("a")); inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w")); const controller = getControllerMovement(); - inputX += controller.x; - inputZ += controller.y; + inputX += controller.moveX; + inputZ += controller.moveY; } + const controller = getControllerMovement(); + if (state.phase === "combat" && !state.paused) { + updateCameraOrbit(cameraOrbit.current, controller.lookX, controller.lookY, delta); + } + setCameraRelativeMovement(cameraRelativeMovement.current, inputX, inputZ, cameraOrbit.current.yaw); + inputX = cameraRelativeMovement.current.x; + inputZ = cameraRelativeMovement.current.z; const length = Math.hypot(inputX, inputZ); if (length > 0.05) { const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length); @@ -518,9 +508,20 @@ function PlayerCharacter() { : "idle"; group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16); - desiredCameraPosition.set(group.current.position.x * 0.45, 5.1, group.current.position.z + 7.7); + const horizontalDistance = Math.cos(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE; + const sinYaw = Math.sin(cameraOrbit.current.yaw); + const cosYaw = Math.cos(cameraOrbit.current.yaw); + desiredCameraPosition.set( + group.current.position.x + sinYaw * horizontalDistance, + CAMERA_FOCUS_HEIGHT + Math.sin(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE, + group.current.position.z + cosYaw * horizontalDistance, + ); camera.position.lerp(desiredCameraPosition, 1 - Math.pow(0.002, delta)); - camera.lookAt(group.current.position.x * 0.55, 0.65, group.current.position.z - 2.8); + camera.lookAt( + group.current.position.x - sinYaw * CAMERA_LOOK_AHEAD, + CAMERA_FOCUS_HEIGHT, + group.current.position.z - cosYaw * CAMERA_LOOK_AHEAD, + ); broadcastTimer.current += delta; if (broadcastTimer.current > 0.15) { @@ -651,6 +652,7 @@ function BullBoss({ bossIndex }: { bossIndex: number }) { const motion = current.motion; targetPosition.set(motion.position[0], 0.03, motion.position[1]); group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta)); + if (!bossCanTrackTarget(current.boss.hp)) return; if (motion.mode === "stacking") { group.current.rotation.y += (Math.PI * 2 / 5) * delta; @@ -678,145 +680,6 @@ function BullBoss({ bossIndex }: { bossIndex: number }) { ); } -type AlternateBossKind = Exclude["boss"]["id"], "bulldrome">; - -interface AlternateBossConfig { - url: string; - scale: number; - idle: string; - move: string; - attack: string; - special: string; - death: string; - light: string; - rotationOffset: number; - prototype?: boolean; - floating?: boolean; -} - -const ALTERNATE_BOSS_CONFIG: Record = { - "sandglass-scorpion": { - url: SANDGLASS_URL, - scale: 0.7, - idle: "Idle", - move: "Burrow", - attack: "Eruption", - special: "Hourglass", - death: "Death", - light: "#e9b94f", - rotationOffset: 0, - }, - "cragclaw-crab": { - url: CRAGCLAW_URL, - scale: 1.2, - idle: "Idle", - move: "Walk", - attack: "Bite_Front", - special: "Bite_InPlace", - death: "Death", - light: "#49d5df", - rotationOffset: 0, - }, - "mournveil-ghost": { - url: MOURNVEIL_URL, - scale: 1.1, - idle: "Flying_Idle", - move: "Fast_Flying", - attack: "Punch", - special: "Headbutt", - death: "Death", - light: "#9d72ff", - rotationOffset: 0, - floating: true, - }, - "crownshard-golem": { - url: CROWNSHARD_URL, - scale: 1.15, - idle: "Flying_Idle", - move: "Fast_Flying", - attack: "Punch", - special: "Headbutt", - death: "Death", - light: "#e0bd45", - rotationOffset: 0, - floating: true, - }, - "crystal-bat-matriarch": { - url: CRYSTAL_BAT_MATRIARCH_URL, - scale: 0.828, - idle: "Idle", - move: "Swoop", - attack: "SonicPulse", - special: "MirrorShatter", - death: "Death", - light: "#8eeaff", - rotationOffset: 0, - floating: true, - }, - "stormwool-alpaca": { - url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0, prototype: true, - }, - "cluckhorn-colossus": { - url: CLAUDE_BOSS_URLS["cluckhorn-colossus"], scale: 2.2, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#f0b85d", rotationOffset: 0, prototype: true, - }, - "ashwing-demon": { - url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, prototype: true, floating: true, - }, - "riftclaw-demon": { - url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0, prototype: true, - }, - "tempestscale-dragon": { - url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, prototype: true, floating: true, - }, - emberfox: { - url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0, prototype: true, - }, - "mirelord-frog": { - url: CLAUDE_BOSS_URLS["mirelord-frog"], scale: 1.4, idle: "Idle", move: "Run", attack: "Punch", special: "Jump", death: "Death", light: "#73c96b", rotationOffset: 0, prototype: true, - }, - "stonebreaker-giant": { - url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0, prototype: true, - }, - "glub-sovereign": { - url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, prototype: true, floating: true, - }, - "scrapking-goblin": { - url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0, prototype: true, - }, - "warcaller-orc": { - url: CLAUDE_BOSS_URLS["warcaller-orc"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#e4533f", rotationOffset: 0, prototype: true, - }, - "tuskmaw-orc": { - url: CLAUDE_BOSS_URLS["tuskmaw-orc"], scale: 1.45, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#9eb25d", rotationOffset: 0, prototype: true, - }, - "broodfang-spider": { - url: CLAUDE_BOSS_URLS["broodfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#b56cff", rotationOffset: 0, prototype: true, - }, - "silkfang-spider": { - url: CLAUDE_BOSS_URLS["silkfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#9d68d8", rotationOffset: 0, prototype: true, - }, - "thorncrown-stag": { - url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0, prototype: true, - }, - "sky-totem": { - url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, prototype: true, floating: true, - }, - "razorcrest-raptor": { - url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0, prototype: true, - }, - "bristlequake-boar": { - url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Death_AnimalArmature", light: "#d47b45", rotationOffset: 0, prototype: true, - }, - "moonfang-wolf": { - url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0, prototype: true, - }, - "frostmaw-yeti": { - url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0, prototype: true, - }, - "rimeclaw-yeti": { - url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0, prototype: true, - }, -}; function alternateBossClip(kind: AlternateBossKind, motion: ReturnType["bossMotion"]) { const config = ALTERNATE_BOSS_CONFIG[kind]; @@ -879,6 +742,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex const floatingHeight = config.floating ? 0.2 : 0.03; targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]); group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta)); + if (!bossCanTrackTarget(current.boss.hp)) return; let targetAngle = Math.atan2( state.partyPositions.brann[0] - motion.position[0], diff --git a/src/components/TopScreen.tsx b/src/components/TopScreen.tsx index 9157ff3..ff8f9de 100644 --- a/src/components/TopScreen.tsx +++ b/src/components/TopScreen.tsx @@ -81,7 +81,7 @@ function BossBar() { if (phase === "briefing") return null; const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)]; return ( -
1 ? "is-dual" : ""}`}> +
1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""}`}> {bosses.map((entry) =>
Vault Beast{entry.name}{Math.ceil((entry.hp / entry.maxHp) * 100)}%
@@ -108,6 +108,7 @@ function EncounterCallout() { function PhaseOverlay() { const phase = useGameStore((state) => state.phase); + const runMode = useGameStore((state) => state.runMode); const primaryBoss = useGameStore((state) => state.boss); const additionalBosses = useGameStore((state) => state.additionalBosses); if (phase === "intermission") return ; @@ -115,6 +116,9 @@ function PhaseOverlay() { const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]); const room = bossRoomFor(primaryBoss.id); const bossNames = bosses.map((boss) => boss.name).join(" & "); + const briefingMode = runMode === "rogue-trials" + ? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round" + : bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial; if (phase === "combat") return null; const title = phase === "briefing" ? room.name @@ -122,7 +126,7 @@ function PhaseOverlay() { ? `${bossNames} Broken` : "Party Broken"; const eyebrow = phase === "briefing" - ? `${bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial} · ${room.biome}` + ? `${briefingMode} · ${room.biome}` : phase === "victory" ? "Encounter Complete" : "Encounter Failed"; @@ -193,7 +197,7 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
-
{runMode === "roguelike" ? `Round ${round}` : "Objective"}{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}
+
{runMode !== "encounter" ? `Round ${round}` : "Objective"}{bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}
WASD Move Q / E Target 1–6 Cast
diff --git a/src/components/boss/BossMechanicIndicators.tsx b/src/components/boss/BossMechanicIndicators.tsx index caa2e26..d2b4c59 100644 --- a/src/components/boss/BossMechanicIndicators.tsx +++ b/src/components/boss/BossMechanicIndicators.tsx @@ -1,10 +1,12 @@ import { useFrame } from "@react-three/fiber"; import { Html } from "@react-three/drei"; -import { useLayoutEffect, useRef, type ComponentType } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react"; import * as THREE from "three"; import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool"; +import { angleTo } from "../../game/geometry"; import { useGameStore } from "../../game/store"; -import type { BossMotionMode, MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types"; +import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types"; +import { BOSS_INDICATOR_DEATH_FADE_MS, advanceBossIndicatorOpacity } from "./bossDeathVisuals"; const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const; const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2); @@ -26,12 +28,91 @@ const INWARD_ARROW_SHAPE = new THREE.Shape() .lineTo(-0.29, 0.04) .lineTo(-0.13, 0.04) .lineTo(-0.13, -0.32); +const SOUL_SIPHON_GUIDANCE_HEIGHT = 2.2; +const SOUL_SIPHON_GUIDANCE_COLOR = "#9dff78"; +const SOUL_SIPHON_GUIDANCE_OUTLINE = "#2b210b"; type GameStoreState = ReturnType; function motionAt(state: GameStoreState, bossIndex: number) { return bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion; } +function bossHpAt(state: GameStoreState, bossIndex: number) { + return bossIndex === 0 ? state.boss.hp : state.additionalBosses[bossIndex - 1]?.boss.hp ?? 0; +} + +function earliestSoulSiphonInMotion(motion: BossMotionState | undefined, current?: SoulSiphonState) { + if (!motion) return current; + let earliest = current; + for (const telegraph of motion.poolTelegraphs) { + const siphon = telegraph.kind === "soul-siphon" && !telegraph.resolved ? telegraph.soulSiphon : undefined; + if (siphon && (!earliest || siphon.nextDamageAt < earliest.nextDamageAt)) earliest = siphon; + } + return earliest; +} + +function activeSoulSiphonGuidance(state: GameStoreState) { + if (state.phase !== "combat") return undefined; + let earliest = state.boss.hp > 0 ? earliestSoulSiphonInMotion(state.bossMotion) : undefined; + for (const entry of state.additionalBosses) { + if (entry.boss.hp > 0) earliest = earliestSoulSiphonInMotion(entry.motion, earliest); + } + return earliest; +} + +function WorldDirectionIndicator({ + origin, + destination, + height, + color, +}: { + origin: WorldPosition; + destination: WorldPosition; + height: number; + color: string; +}) { + const group = useRef(null); + const fill = useRef(null); + const reducedMotion = document.documentElement.classList.contains("force-reduced-motion") + || window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + useFrame(({ clock }) => { + if (!group.current) return; + const wave = reducedMotion ? 0.5 : (Math.sin(clock.elapsedTime * 5.2) + 1) * 0.5; + group.current.position.set(origin[0], height + (reducedMotion ? 0 : (wave - 0.5) * 0.12), origin[1]); + group.current.rotation.y = angleTo(origin, destination) + Math.PI; + group.current.scale.setScalar(1.55 + wave * 0.16); + if (fill.current) fill.current.opacity = 0.82 + wave * 0.18; + }); + + return ( + + + + + + + + + + + ); +} + +function SoulSiphonGuidanceIndicator() { + const guidance = useGameStore(activeSoulSiphonGuidance); + const playerPosition = useGameStore((state) => state.partyPositions.aelia); + if (!guidance) return null; + return ( + + ); +} + export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) { const phase = useGameStore((state) => state.phase); const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode); @@ -644,14 +725,63 @@ const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] PooledMechanicIndicators, ]; +function BossMechanicIndicatorSet({ bossIndex }: { bossIndex: number }) { + const defeated = useGameStore((state) => bossHpAt(state, bossIndex) <= 0); + const group = useRef(null); + const opacity = useRef(defeated ? 0 : 1); + const patchedMeshes = useRef(new WeakSet()); + const [retainIndicators, setRetainIndicators] = useState(!defeated); + + const patchMeshOpacity = useCallback((child: THREE.Object3D) => { + if (!(child instanceof THREE.Mesh) || patchedMeshes.current.has(child)) return; + patchedMeshes.current.add(child); + const previousBeforeRender = child.onBeforeRender; + const previousAfterRender = child.onAfterRender; + const materials = Array.isArray(child.material) ? child.material : [child.material]; + const sourceOpacities = new Float32Array(materials.length); + child.onBeforeRender = (renderer, scene, camera, geometry, material, renderGroup) => { + previousBeforeRender.call(child, renderer, scene, camera, geometry, material, renderGroup); + for (let index = 0; index < materials.length; index += 1) { + sourceOpacities[index] = materials[index].opacity; + materials[index].opacity *= opacity.current; + } + }; + child.onAfterRender = (renderer, scene, camera, geometry, material, renderGroup) => { + previousAfterRender.call(child, renderer, scene, camera, geometry, material, renderGroup); + for (let index = 0; index < materials.length; index += 1) materials[index].opacity = sourceOpacities[index]; + }; + }, []); + + useEffect(() => { + if (!defeated) { + setRetainIndicators(true); + return; + } + const timeout = window.setTimeout(() => setRetainIndicators(false), BOSS_INDICATOR_DEATH_FADE_MS); + return () => window.clearTimeout(timeout); + }, [defeated]); + + useFrame((_, delta) => { + if (!group.current) return; + opacity.current = advanceBossIndicatorOpacity(opacity.current, defeated, delta); + group.current.visible = opacity.current > 0; + group.current.traverse(patchMeshOpacity); + }); + + return ( + + {retainIndicators && BOSS_MECHANIC_INDICATORS.map((Indicator) => )} + + ); +} + export function BossMechanicIndicators() { const bossCount = useGameStore((state) => state.additionalBosses.length + 1); return ( <> + {Array.from({ length: bossCount }, (_, bossIndex) => ( - - {BOSS_MECHANIC_INDICATORS.map((Indicator) => )} - + ))} ); diff --git a/src/components/boss/bossDeathVisuals.test.ts b/src/components/boss/bossDeathVisuals.test.ts new file mode 100644 index 0000000..1e83976 --- /dev/null +++ b/src/components/boss/bossDeathVisuals.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + BOSS_INDICATOR_DEATH_FADE_MS, + advanceBossIndicatorOpacity, + bossCanTrackTarget, +} from "./bossDeathVisuals"; + +describe("boss death visuals", () => { + it("stops target tracking as soon as boss health reaches zero", () => { + expect(bossCanTrackTarget(1)).toBe(true); + expect(bossCanTrackTarget(0)).toBe(false); + expect(bossCanTrackTarget(-1)).toBe(false); + }); + + it("fades mechanic indicators to zero over the death transition", () => { + const halfway = advanceBossIndicatorOpacity(1, true, BOSS_INDICATOR_DEATH_FADE_MS / 2_000); + expect(halfway).toBeCloseTo(0.5); + expect(advanceBossIndicatorOpacity(halfway, true, BOSS_INDICATOR_DEATH_FADE_MS / 2_000)).toBe(0); + expect(advanceBossIndicatorOpacity(0.4, false, 1 / 60)).toBe(1); + }); +}); diff --git a/src/components/boss/bossDeathVisuals.ts b/src/components/boss/bossDeathVisuals.ts new file mode 100644 index 0000000..a516882 --- /dev/null +++ b/src/components/boss/bossDeathVisuals.ts @@ -0,0 +1,10 @@ +export const BOSS_INDICATOR_DEATH_FADE_MS = 250; + +export function bossCanTrackTarget(hp: number) { + return hp > 0; +} + +export function advanceBossIndicatorOpacity(current: number, defeated: boolean, deltaSeconds: number) { + if (!defeated) return 1; + return Math.max(0, current - (deltaSeconds * 1_000) / BOSS_INDICATOR_DEATH_FADE_MS); +} diff --git a/src/frontend/accountRepository.test.ts b/src/frontend/accountRepository.test.ts index 44b4443..5052208 100644 --- a/src/frontend/accountRepository.test.ts +++ b/src/frontend/accountRepository.test.ts @@ -1,53 +1,38 @@ -import { describe, expect, it } from "vitest"; -import type { StorageAdapter } from "./saveRepository"; +import { describe, expect, it, vi } from "vitest"; import { AccountRepository } from "./accountRepository"; +import { OnlineApiError, type OnlineRepository } from "./onlineRepository"; -function memoryStorage(): StorageAdapter { - const data = new Map(); +function onlineStub(overrides: Partial = {}): OnlineRepository { return { - getItem: (key) => data.get(key) ?? null, - setItem: (key, value) => { data.set(key, value); }, - }; + register: vi.fn(async (username: string) => ({ id: 1, username })), + login: vi.fn(async (username: string) => ({ id: 1, username })), + session: vi.fn(async () => null), + logout: vi.fn(async () => undefined), + ...overrides, + } as unknown as OnlineRepository; } -const testHasher = async (password: string, salt: string) => { - const checksum = [...password].reduce((total, character) => total + character.charCodeAt(0), 0); - return `derived:${salt}:${checksum}`; -}; - describe("AccountRepository", () => { - it("requires both a username and password", async () => { - const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt"); - + it("requires both username and password before contacting server", async () => { + const online = onlineStub(); + const repository = new AccountRepository(online); await expect(repository.create("", "secret")).resolves.toEqual({ ok: false, reason: "missing-credentials" }); - await expect(repository.create("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" }); await expect(repository.authenticate("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" }); + expect(online.register).not.toHaveBeenCalled(); }); - it("requires account creation before sign-in", async () => { - const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt"); - - await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: false, reason: "account-not-found" }); - await expect(repository.create("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" }); - await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" }); + it("creates and authenticates real server accounts", async () => { + const repository = new AccountRepository(onlineStub()); + await expect(repository.create("Wayfinder", "long-password")).resolves.toEqual({ ok: true, username: "Wayfinder" }); + await expect(repository.authenticate("Wayfinder", "long-password")).resolves.toEqual({ ok: true, username: "Wayfinder" }); }); - it("rejects an incorrect password and duplicate account names", async () => { - const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt"); - await repository.create("Wayfinder", "correct"); - - await expect(repository.authenticate("wayfinder", "wrong")).resolves.toEqual({ ok: false, reason: "invalid-password" }); - await expect(repository.create(" wayfinder ", "another")).resolves.toEqual({ ok: false, reason: "account-exists" }); - }); - - it("persists only a derived password verifier", async () => { - const storage = memoryStorage(); - const repository = new AccountRepository(storage, testHasher, () => "unique-salt"); - await repository.create("healer", "plaintext-secret"); - - const persisted = storage.getItem("i-want-to-heal:accounts:v1") ?? ""; - expect(persisted).toContain("derived:unique-salt:"); - expect(persisted).not.toContain("plaintext-secret"); - expect(JSON.parse(persisted).healer).not.toHaveProperty("password"); + it("maps server conflicts, invalid credentials, and outages", async () => { + const conflict = new AccountRepository(onlineStub({ register: vi.fn(async () => { throw new OnlineApiError("exists", 409); }) })); + await expect(conflict.create("Wayfinder", "long-password")).resolves.toMatchObject({ ok: false, reason: "account-exists" }); + const invalid = new AccountRepository(onlineStub({ login: vi.fn(async () => { throw new OnlineApiError("bad login", 401); }) })); + await expect(invalid.authenticate("Wayfinder", "wrong-password")).resolves.toMatchObject({ ok: false, reason: "invalid-password" }); + const outage = new AccountRepository(onlineStub({ login: vi.fn(async () => { throw new OnlineApiError("offline", 0); }) })); + await expect(outage.authenticate("Wayfinder", "long-password")).resolves.toMatchObject({ ok: false, reason: "server-unavailable" }); }); }); diff --git a/src/frontend/accountRepository.ts b/src/frontend/accountRepository.ts index 2a3b749..441d221 100644 --- a/src/frontend/accountRepository.ts +++ b/src/frontend/accountRepository.ts @@ -1,132 +1,40 @@ -import type { StorageAdapter } from "./saveRepository"; - -const ACCOUNTS_KEY = "i-want-to-heal:accounts:v1"; -const PASSWORD_ITERATIONS = 120_000; - -interface AccountRecord { - username: string; - salt: string; - passwordHash: string; -} - -type AccountMap = Record; -type PasswordHasher = (password: string, salt: string) => Promise; +import { OnlineApiError, OnlineRepository, onlineRepository } from "./onlineRepository"; export type AccountResult = | { ok: true; username: string } - | { ok: false; reason: "missing-credentials" | "account-exists" | "account-not-found" | "invalid-password" | "storage-unavailable" }; + | { ok: false; reason: "missing-credentials" | "account-exists" | "invalid-password" | "server-unavailable" | "invalid-request"; message?: string }; -const fallbackMemory = new Map(); -const fallbackStorage: StorageAdapter = { - getItem: (key) => fallbackMemory.get(key) ?? null, - setItem: (key, value) => { fallbackMemory.set(key, value); }, -}; - -function browserStorage(): StorageAdapter { - try { - if (typeof localStorage !== "undefined") return localStorage; - } catch { - // Android WebView can deny storage before its host is ready. - } - return fallbackStorage; +function failure(error: unknown): Extract { + if (!(error instanceof OnlineApiError)) return { ok: false, reason: "server-unavailable" }; + if (error.status === 0 || error.status >= 500) return { ok: false, reason: "server-unavailable", message: error.message }; + if (error.status === 409) return { ok: false, reason: "account-exists", message: error.message }; + if (error.status === 401) return { ok: false, reason: "invalid-password", message: error.message }; + return { ok: false, reason: "invalid-request", message: error.message }; } -function encodeBytes(bytes: Uint8Array) { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); -} - -function decodeBytes(value: string) { - const binary = atob(value); - return Uint8Array.from(binary, (character) => character.charCodeAt(0)); -} - -async function hashPassword(password: string, salt: string) { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(password), - "PBKDF2", - false, - ["deriveBits"], - ); - const bits = await crypto.subtle.deriveBits({ - name: "PBKDF2", - hash: "SHA-256", - salt: decodeBytes(salt), - iterations: PASSWORD_ITERATIONS, - }, key, 256); - return encodeBytes(new Uint8Array(bits)); -} - -function randomSalt() { - const salt = new Uint8Array(16); - crypto.getRandomValues(salt); - return encodeBytes(salt); -} - -function canonicalUsername(username: string) { - return username.trim().toLocaleLowerCase(); -} - -function parseAccounts(raw: string | null): AccountMap { - if (!raw) return {}; - try { - const parsed = JSON.parse(raw) as AccountMap; - return parsed && typeof parsed === "object" ? parsed : {}; - } catch { - return {}; - } -} - -/** - * Local prototype account registry. Passwords are salted and derived before - * persistence; replace this adapter with the server authentication API when - * remote sync leaves local prototype storage. - */ export class AccountRepository { - constructor( - private readonly storage: StorageAdapter = browserStorage(), - private readonly hasher: PasswordHasher = hashPassword, - private readonly createSalt: () => string = randomSalt, - ) {} - - async create(usernameInput: string, password: string): Promise { - const username = usernameInput.trim(); - const canonical = canonicalUsername(username); - if (!canonical || !password) return { ok: false, reason: "missing-credentials" }; - - const accounts = this.read(); - if (accounts[canonical]) return { ok: false, reason: "account-exists" }; + constructor(private readonly online: OnlineRepository = onlineRepository) {} + async create(username: string, password: string): Promise { + if (!username.trim() || !password) return { ok: false, reason: "missing-credentials" }; try { - const salt = this.createSalt(); - accounts[canonical] = { username, salt, passwordHash: await this.hasher(password, salt) }; - this.storage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); - return { ok: true, username }; - } catch { - return { ok: false, reason: "storage-unavailable" }; + const account = await this.online.register(username, password); + return { ok: true, username: account.username }; + } catch (error) { + return failure(error); } } - async authenticate(usernameInput: string, password: string): Promise { - const canonical = canonicalUsername(usernameInput); - if (!canonical || !password) return { ok: false, reason: "missing-credentials" }; - - const account = this.read()[canonical]; - if (!account) return { ok: false, reason: "account-not-found" }; - + async authenticate(username: string, password: string): Promise { + if (!username.trim() || !password) return { ok: false, reason: "missing-credentials" }; try { - const passwordHash = await this.hasher(password, account.salt); - return passwordHash === account.passwordHash - ? { ok: true, username: account.username } - : { ok: false, reason: "invalid-password" }; - } catch { - return { ok: false, reason: "storage-unavailable" }; + const account = await this.online.login(username, password); + return { ok: true, username: account.username }; + } catch (error) { + return failure(error); } } - private read() { - return parseAccounts(this.storage.getItem(ACCOUNTS_KEY)); - } + session() { return this.online.session(); } + logout() { return this.online.logout(); } } diff --git a/src/frontend/data.test.ts b/src/frontend/data.test.ts index eb089f3..2efb8b2 100644 --- a/src/frontend/data.test.ts +++ b/src/frontend/data.test.ts @@ -7,6 +7,7 @@ import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog"; describe("game mode configuration", () => { it("separates randomized PVE from selectable Dungeons", () => { expect(MODE_COPY["roguelike-pve"].title).toBe("PVE"); + expect(MODE_COPY["rogue-trials"].detail).toContain("unseen"); expect(MODE_COPY.dungeons.title).toBe("Dungeons"); }); @@ -33,4 +34,12 @@ describe("game mode configuration", () => { ); } }); + + it("derives trophy ownership from the saved boss pet collection", () => { + const collections = buildCollections({ dropsFound: {}, petsFound: { "bulldrome-pet": 1 } }, { bulldrome: 37 }); + const bulldrome = collections.flatMap((group) => group.bosses).find((boss) => boss.bossId === "bulldrome"); + expect(bulldrome?.pet.count).toBe(1); + expect(bulldrome?.pet.chance).toBe("1 in 500"); + expect(bulldrome?.kills).toBe(37); + }); }); diff --git a/src/frontend/data.ts b/src/frontend/data.ts index a4bdf54..251109c 100644 --- a/src/frontend/data.ts +++ b/src/frontend/data.ts @@ -70,7 +70,14 @@ export const MODE_COPY: Record(); + return { + getItem: (key) => memory.get(key) ?? null, + setItem: (key, value) => { memory.set(key, value); }, + removeItem: (key) => { memory.delete(key); }, + }; +} + +export class OnlineApiError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } +} + +export class OnlineRepository { + private readonly baseUrl: string; + + constructor( + private readonly requester: Requester = (...args) => fetch(...args), + private readonly storage: TokenStorage = browserStorage(), + baseUrl: string = defaultApiBaseUrl(), + ) { + this.baseUrl = baseUrl.replace(/\/$/, ""); + } + + private async request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + const token = this.storage.getItem(TOKEN_KEY); + if (token) headers.set("Authorization", `Bearer ${token}`); + let response: Response; + try { + response = await this.requester(`${this.baseUrl}${path}`, { ...init, headers }); + } catch { + throw new OnlineApiError("Online server is unreachable.", 0); + } + const body = await response.json().catch(() => ({})) as { error?: string } & T; + if (!response.ok) throw new OnlineApiError(body.error ?? "Online request failed.", response.status); + return body; + } + + private rememberAuth(result: { account: OnlineAccount; token: string }) { + this.storage.setItem(TOKEN_KEY, result.token); + return result.account; + } + + async register(username: string, password: string): Promise { + return this.rememberAuth(await this.request<{ account: OnlineAccount; token: string }>("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + })); + } + + async login(username: string, password: string): Promise { + return this.rememberAuth(await this.request<{ account: OnlineAccount; token: string }>("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + })); + } + + async session(): Promise { + if (!this.storage.getItem(TOKEN_KEY)) return null; + try { + return (await this.request<{ account: OnlineAccount }>("/api/auth/session")).account; + } catch (error) { + if (error instanceof OnlineApiError && error.status === 401) this.storage.removeItem(TOKEN_KEY); + if (error instanceof OnlineApiError && error.status === 401) return null; + throw error; + } + } + + async logout(): Promise { + try { await this.request("/api/auth/logout", { method: "POST" }); } + finally { this.storage.removeItem(TOKEN_KEY); } + } + + async listSaves(): Promise { + return (await this.request<{ slots: OnlineSaveSlot[] }>("/api/saves")).slots; + } + + async writeSave(save: HunterSave): Promise { + return (await this.request<{ save: HunterSave }>(`/api/saves/${save.slotId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ save }), + })).save; + } + + async readSave(slotId: SaveSlotId): Promise { + return (await this.request<{ save: HunterSave | null }>(`/api/saves/${slotId}`)).save; + } + + bossLeaderboard(bossId: BossId, slotId: SaveSlotId): Promise { + return this.request(`/api/leaderboards/boss/${encodeURIComponent(bossId)}?slot=${slotId}`); + } + + roguelikeLeaderboard(slotId: SaveSlotId): Promise { + return this.request(`/api/leaderboards/roguelike?slot=${slotId}`); + } +} + +export const onlineRepository = new OnlineRepository(); diff --git a/src/frontend/saveRepository.test.ts b/src/frontend/saveRepository.test.ts index 2bc5730..59f5626 100644 --- a/src/frontend/saveRepository.test.ts +++ b/src/frontend/saveRepository.test.ts @@ -16,7 +16,7 @@ describe("SaveRepository", () => { const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z"); repository.create(2, "Seraphine"); - const slots = repository.list(null); + const slots = repository.listLocal(); expect(slots.map((slot) => slot.id)).toEqual([1, 2, 3]); expect(slots[1].local?.updatedAt).toBe("2026-07-10T12:00:00.000Z"); expect(slots[1].local?.hunterName).toBe("Seraphine"); @@ -34,41 +34,30 @@ describe("SaveRepository", () => { healers: { ...save.healers, druid: { ...save.healers.druid, level: 99 } }, })); - const slots = repository.list(null); + const slots = repository.listLocal(); expect(slots[0].local?.healers.druid.level).toBe(1); expect(slots[2].local?.healers.druid.level).toBe(99); expect(slots[2].local?.slotId).toBe(3); }); - it("uploads local state and can later overwrite it with the online version", () => { + it("replaces a local slot with a downloaded server snapshot", () => { let now = "2026-07-10T12:00:00.000Z"; const repository = new SaveRepository(memoryStorage(), () => now); - repository.create(1, "Aelia"); - now = "2026-07-10T13:00:00.000Z"; - repository.upload(1, "healer@example.com"); - repository.updateLocal(1, (save) => ({ - ...save, - healers: { ...save.healers, priest: { ...save.healers.priest, level: 40 } }, - })); - - expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(40); - expect(repository.list("healer@example.com")[0].online?.healers.priest.level).toBe(1); - + const serverSave = repository.create(1, "Aelia"); + serverSave.healers.priest.level = 40; now = "2026-07-10T14:00:00.000Z"; - repository.download(1, "healer@example.com"); - expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(1); - expect(repository.list("healer@example.com")[0].local?.updatedAt).toBe(now); + repository.replaceLocal(serverSave); + expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40); + expect(repository.listLocal()[0].local?.updatedAt).toBe(now); }); - it("deletes only the local copy so the online record can restore it", () => { + it("deletes the local copy without inventing an online record", () => { const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z"); repository.create(1, "Aelia"); - repository.upload(1, "healer"); repository.deleteLocal(1); - - const slot = repository.list("healer")[0]; + const slot = repository.listLocal()[0]; expect(slot.local).toBeNull(); - expect(slot.online).not.toBeNull(); + expect(slot.online).toBeNull(); }); it("keeps class progression and inventories independent under one hunter name", () => { @@ -83,7 +72,7 @@ describe("SaveRepository", () => { }, })); - const save = repository.list(null)[0].local!; + const save = repository.listLocal()[0].local!; expect(save.hunterName).toBe("Aelia"); expect(save.activeClassId).toBe("druid"); expect(save.healers.druid.level).toBe(8); @@ -102,7 +91,7 @@ describe("SaveRepository", () => { activeClassId: "druid", playSeconds: 999, healers: Object.fromEntries(Object.entries(created.healers).map(([id, healer]) => [id, { ...healer, level: 27 }])), - stats: { totalBossKills: 22, flawlessClears: 9, alliesSaved: 4, healingDone: 1200, bossKills: { bulldrome: 22 } }, + stats: { totalBossKills: 22, flawlessClears: 9, alliesSaved: 4, healingDone: 1200, bossKills: { bulldrome: 22 }, highestRoguelikeRound: 12 }, materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 99, rarity: "common", itemLevel: 1, glyph: "R" }], collectionLog: { dropsFound: { "legacy-boss-coin": 99 }, petsFound: { "bulldrome-pet": 1 } }, gearProgress: Object.fromEntries(Object.entries(created.gearProgress).map(([id, owner]) => [id, { @@ -113,39 +102,19 @@ describe("SaveRepository", () => { } as Record; storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy })); - const migrated = repository.list(null)[0].local!; + const migrated = repository.listLocal()[0].local!; expect(migrated.schemaVersion).toBe(5); expect(migrated.hunterName).toBe("Legacy"); expect(migrated.activeClassId).toBe("priest"); expect(migrated.playSeconds).toBe(0); expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true); - expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {} }); + expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0 }); expect(migrated.materials).toEqual([]); expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} }); expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true); expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(5); }); - it("resets and persists legacy cloud saves when they are listed", () => { - const storage = memoryStorage(); - const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); - const created = repository.create(1, "Cloud Legacy"); - const legacy = { - ...created, - schemaVersion: 4, - stats: { ...created.stats, totalBossKills: 8, bossKills: { bulldrome: 8 } }, - materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 8, rarity: "common", itemLevel: 1, glyph: "R" }], - }; - const cloudKey = "i-want-to-heal:saves:cloud:v1:cloud@example.com"; - storage.setItem(cloudKey, JSON.stringify({ 1: legacy })); - - const online = repository.list("cloud@example.com")[0].online!; - expect(online.schemaVersion).toBe(5); - expect(online.stats.totalBossKills).toBe(0); - expect(online.materials).toEqual([]); - expect(JSON.parse(storage.getItem(cloudKey) ?? "{}")["1"].schemaVersion).toBe(5); - }); - it("preserves valid v5 progression and group-drop inventory", () => { const storage = memoryStorage(); const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); @@ -157,7 +126,7 @@ describe("SaveRepository", () => { created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } }; storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); - const migrated = repository.list(null)[0].local!; + const migrated = repository.listLocal()[0].local!; expect(migrated.schemaVersion).toBe(5); expect(migrated.healers.priest.level).toBe(8); expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 }); @@ -176,7 +145,7 @@ describe("SaveRepository", () => { created.gearProgress.brann.passiveInfusionId = "deep-wells" as never; storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); - const migrated = repository.list(null)[0].local!; + const migrated = repository.listLocal()[0].local!; expect(migrated.schemaVersion).toBe(5); expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary"); expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull(); @@ -192,7 +161,7 @@ describe("SaveRepository", () => { const created = repository.create(1, "Infused"); created.gearProgress.priest.passiveInfusionId = passiveId; storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); - expect(repository.list(null)[0].local?.gearProgress.priest.passiveInfusionId).toBe(passiveId); + expect(repository.listLocal()[0].local?.gearProgress.priest.passiveInfusionId).toBe(passiveId); } }); }); diff --git a/src/frontend/saveRepository.ts b/src/frontend/saveRepository.ts index 677192a..e4c3c8e 100644 --- a/src/frontend/saveRepository.ts +++ b/src/frontend/saveRepository.ts @@ -15,7 +15,6 @@ export interface StorageAdapter { type SaveMap = Partial>; const LOCAL_KEY = "i-want-to-heal:saves:local:v1"; -const CLOUD_KEY = (accountId: string) => `i-want-to-heal:saves:cloud:v1:${accountId.toLowerCase()}`; const SLOT_IDS: SaveSlotId[] = [1, 2, 3]; const fallbackMemory = new Map(); @@ -150,6 +149,7 @@ function normalizeSave(value: unknown): HunterSave | null { alliesSaved: Math.max(0, candidate.stats?.alliesSaved ?? 0), healingDone: Math.max(0, candidate.stats?.healingDone ?? 0), bossKills, + highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)), }, materials: normalizeMaterials(candidate.materials, collectionLog), collectionLog, @@ -181,10 +181,9 @@ export class SaveRepository { private readonly now: () => string = () => new Date().toISOString(), ) {} - list(accountId: string | null): SaveSlotState[] { + listLocal(): SaveSlotState[] { const local = this.read(LOCAL_KEY); - const online = accountId ? this.read(CLOUD_KEY(accountId)) : {}; - return SLOT_IDS.map((id) => ({ id, local: local[id] ?? null, online: online[id] ?? null })); + return SLOT_IDS.map((id) => ({ id, local: local[id] ?? null, online: null })); } create(slotId: SaveSlotId, hunterName: string): HunterSave { @@ -223,23 +222,10 @@ export class SaveRepository { return copy; } - upload(slotId: SaveSlotId, accountId: string): HunterSave | null { - const local = this.read(LOCAL_KEY)[slotId]; - if (!local) return null; - const cloud = this.read(CLOUD_KEY(accountId)); - const uploaded = { ...cloneSave(local), updatedAt: this.now() }; - cloud[slotId] = uploaded; - this.write(CLOUD_KEY(accountId), cloud); - this.setLocal(uploaded); - return uploaded; - } - - download(slotId: SaveSlotId, accountId: string): HunterSave | null { - const cloud = this.read(CLOUD_KEY(accountId))[slotId]; - if (!cloud) return null; - const downloaded = { ...cloneSave(cloud), slotId, updatedAt: this.now() }; - this.setLocal(downloaded); - return downloaded; + replaceLocal(save: HunterSave): HunterSave { + const normalized = { ...cloneSave(save), updatedAt: this.now() }; + this.setLocal(normalized); + return normalized; } private setLocal(save: HunterSave): void { diff --git a/src/frontend/store.ts b/src/frontend/store.ts index c796a70..ea32697 100644 --- a/src/frontend/store.ts +++ b/src/frontend/store.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { DEFAULT_SETTINGS, normalizeHunterName } from "./data"; import { SaveRepository } from "./saveRepository"; import { AccountRepository, type AccountResult } from "./accountRepository"; +import { onlineRepository, type OnlineSaveSlot } from "./onlineRepository"; import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types"; import type { AbilityId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types"; import { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike"; @@ -12,10 +13,23 @@ import { infusionsForOwner, } from "../game/progression/infusions"; import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot"; +import { highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats"; const repository = new SaveRepository(); const accounts = new AccountRepository(); const SETTINGS_KEY = "i-want-to-heal:settings:v1"; +const onlineSaveQueues = new Map>(); + +function writeServerSaveSerially(save: HunterSave): Promise { + const previous = onlineSaveQueues.get(save.slotId); + const next = (previous ? previous.catch(() => save) : Promise.resolve(save)) + .then(() => onlineRepository.writeSave(save)); + onlineSaveQueues.set(save.slotId, next); + void next.finally(() => { + if (onlineSaveQueues.get(save.slotId) === next) onlineSaveQueues.delete(save.slotId); + }).catch(() => undefined); + return next; +} function loadSettings(): GameSettings { try { @@ -38,14 +52,30 @@ function accountNotice(result: Extract, action: "s switch (result.reason) { case "missing-credentials": return "Enter both username and password."; case "account-exists": return "Account already exists. Sign in with its password."; - case "account-not-found": return "Account not found. Create an account before enabling online sync."; case "invalid-password": return "Username or password is incorrect."; - case "storage-unavailable": return action === "create" - ? "Account could not be saved on this device. Continue offline or try again." - : "Account could not be verified on this device. Continue offline or try again."; + case "server-unavailable": return "Online server is unreachable. Continue offline or try again."; + case "invalid-request": return result.message ?? (action === "create" ? "Account could not be created." : "Sign-in failed."); } } +function refreshLocalSlots(current: readonly SaveSlotState[]): SaveSlotState[] { + return repository.listLocal().map((slot) => ({ + ...slot, + online: current.find((candidate) => candidate.id === slot.id)?.online ?? null, + })); +} + +function mergeServerSlots(serverSlots: readonly OnlineSaveSlot[]): SaveSlotState[] { + return repository.listLocal().map((slot) => ({ + ...slot, + online: serverSlots.find((candidate) => candidate.slotId === slot.id)?.save ?? null, + })); +} + +function replaceOnlineSlot(current: readonly SaveSlotState[], save: HunterSave): SaveSlotState[] { + return refreshLocalSlots(current).map((slot) => slot.id === save.slotId ? { ...slot, online: save } : slot); +} + export interface FrontendState { screen: AppScreen; accountId: string | null; @@ -64,6 +94,7 @@ export interface FrontendState { recentRewards: BossRewardAward[]; settings: GameSettings; notice: string; + restoreSession: () => Promise; signIn: (username: string, password: string) => Promise; createAccount: (username: string, password: string) => Promise; continueOffline: () => void; @@ -74,8 +105,8 @@ export interface FrontendState { playSlot: (slotId: SaveSlotId) => void; deleteSlot: (slotId: SaveSlotId) => void; copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void; - uploadSlot: (slotId: SaveSlotId) => void; - downloadSlot: (slotId: SaveSlotId) => void; + uploadSlot: (slotId: SaveSlotId) => Promise; + downloadSlot: (slotId: SaveSlotId) => Promise; selectMode: (mode: GameModeId) => void; selectBoss: (bossId: BossId) => void; selectDifficulty: (difficultySlug: DifficultySlug) => void; @@ -93,6 +124,7 @@ export interface FrontendState { updateSetting: (key: K, value: GameSettings[K]) => void; touchActiveSave: () => void; recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null; + recordRoguelikeDefeat: (round: number) => void; clearRecentRewards: () => void; clearNotice: () => void; } @@ -104,7 +136,7 @@ function activeSave(slots: SaveSlotState[], activeSlotId: SaveSlotId | null): Hu export const useFrontendStore = create((set, get) => ({ screen: "login", accountId: null, - slots: repository.list(null), + slots: repository.listLocal(), selectedSlotId: 1, activeSlotId: null, selectedMode: "roguelike-pve", @@ -120,14 +152,32 @@ export const useFrontendStore = create((set, get) => ({ settings: loadSettings(), notice: "", + restoreSession: async () => { + try { + const account = await accounts.session(); + if (!account) return false; + const serverSlots = await onlineRepository.listSaves(); + set({ accountId: account.username, slots: mergeServerSlots(serverSlots), screen: "saves", notice: `Online session restored for ${account.username}.` }); + return true; + } catch { + return false; + } + }, + signIn: async (username, password) => { const result = await accounts.authenticate(username, password); if (!result.ok) { set({ notice: accountNotice(result, "sign-in") }); return false; } - set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Online sync connected as ${result.username}.` }); - return true; + try { + const serverSlots = await onlineRepository.listSaves(); + set({ accountId: result.username, slots: mergeServerSlots(serverSlots), screen: "saves", notice: `Online sync connected as ${result.username}.` }); + return true; + } catch { + set({ notice: "Signed in, but server saves could not be loaded." }); + return false; + } }, createAccount: async (username, password) => { const result = await accounts.create(username, password); @@ -135,11 +185,20 @@ export const useFrontendStore = create((set, get) => ({ set({ notice: accountNotice(result, "create") }); return false; } - set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Account created. Online sync connected as ${result.username}.` }); - return true; + try { + const serverSlots = await onlineRepository.listSaves(); + set({ accountId: result.username, slots: mergeServerSlots(serverSlots), screen: "saves", notice: `Account created. Online sync connected as ${result.username}.` }); + return true; + } catch { + set({ notice: "Account created, but server saves could not be loaded." }); + return false; + } + }, + continueOffline: () => set({ accountId: null, slots: repository.listLocal(), screen: "saves", notice: "Offline saves ready." }), + signOut: () => { + void accounts.logout(); + set({ accountId: null, slots: repository.listLocal(), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." }); }, - continueOffline: () => set({ accountId: null, slots: repository.list(null), screen: "saves", notice: "Offline saves ready." }), - signOut: () => set({ accountId: null, slots: repository.list(null), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." }), navigate: (screen) => set({ screen, notice: "" }), selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }), createSlot: (slotId, rawHunterName) => { @@ -149,18 +208,18 @@ export const useFrontendStore = create((set, get) => ({ return false; } repository.create(slotId, hunterName); - set((state) => ({ slots: repository.list(state.accountId), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` })); + set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` })); return true; }, playSlot: (slotId) => { const local = repository.touch(slotId); if (!local) return; - set((state) => ({ activeSlotId: slotId, selectedSlotId: slotId, slots: repository.list(state.accountId), screen: "home", notice: "Offline save loaded." })); + set((state) => ({ activeSlotId: slotId, selectedSlotId: slotId, slots: refreshLocalSlots(state.slots), screen: "home", notice: "Save loaded." })); }, deleteSlot: (slotId) => { repository.deleteLocal(slotId); set((state) => ({ - slots: repository.list(state.accountId), + slots: refreshLocalSlots(state.slots), activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId, notice: `Local slot ${slotId} deleted. Online copy preserved.`, })); @@ -168,19 +227,36 @@ export const useFrontendStore = create((set, get) => ({ copySlot: (sourceId, targetId) => { const copy = repository.copyLocal(sourceId, targetId); if (!copy) return; - set((state) => ({ slots: repository.list(state.accountId), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` })); + set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` })); }, - uploadSlot: (slotId) => { + uploadSlot: async (slotId) => { const { accountId } = get(); if (!accountId) return set({ notice: "Sign in before syncing online." }); - const uploaded = repository.upload(slotId, accountId); - set({ slots: repository.list(accountId), notice: uploaded ? `Slot ${slotId} synced to server.` : "No offline save to sync." }); + const local = repository.listLocal().find((slot) => slot.id === slotId)?.local; + if (!local) return set({ notice: "No offline save to sync." }); + try { + const uploaded = await writeServerSaveSerially(local); + if (get().accountId === accountId) { + set((state) => ({ slots: replaceOnlineSlot(state.slots, uploaded), notice: `Slot ${slotId} synced to TrueNAS.` })); + } + } catch (error) { + set({ notice: error instanceof Error ? error.message : "Save upload failed." }); + } }, - downloadSlot: (slotId) => { + downloadSlot: async (slotId) => { const { accountId } = get(); if (!accountId) return set({ notice: "Sign in before downloading an online save." }); - const downloaded = repository.download(slotId, accountId); - set({ slots: repository.list(accountId), notice: downloaded ? `Slot ${slotId} overwritten with online version.` : "No online version exists for this slot." }); + try { + await onlineSaveQueues.get(slotId)?.catch(() => undefined); + const serverSave = await onlineRepository.readSave(slotId); + if (!serverSave) return set({ notice: "No online version exists for this slot." }); + const downloaded = repository.replaceLocal(serverSave); + if (get().accountId === accountId) { + set((state) => ({ slots: replaceOnlineSlot(state.slots, downloaded), notice: `Slot ${slotId} downloaded from TrueNAS.` })); + } + } catch (error) { + set({ notice: error instanceof Error ? error.message : "Save download failed." }); + } }, selectMode: (selectedMode) => set({ selectedMode, screen: "mode", notice: "" }), selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }), @@ -218,7 +294,7 @@ export const useFrontendStore = create((set, get) => ({ return save; } }); - set({ slots: repository.list(accountId), notice: message }); + set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message })); return upgraded; }, equipSelectedInfusion: () => { @@ -238,7 +314,7 @@ export const useFrontendStore = create((set, get) => ({ return save; } }); - set({ slots: repository.list(accountId), notice: message }); + set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message })); return equipped; }, equipPassiveInfusion: (passiveId) => { @@ -257,7 +333,7 @@ export const useFrontendStore = create((set, get) => ({ return save; } }); - set({ slots: repository.list(accountId), notice: message }); + set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message })); return equipped; }, selectHealerClass: (classId) => { @@ -266,7 +342,7 @@ export const useFrontendStore = create((set, get) => ({ const updated = repository.updateLocal(activeSlotId, (save) => ({ ...save, activeClassId: classId })); if (!updated) return; set({ - slots: repository.list(accountId), + slots: refreshLocalSlots(get().slots), selectedGearOwnerId: classId, selectedInfusionId: infusionsForOwner(classId)[0].id, notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.`, @@ -282,7 +358,7 @@ export const useFrontendStore = create((set, get) => ({ [save.activeClassId]: { ...save.healers[save.activeClassId], inventory: structuredClone(inventory) }, }, })); - set({ slots: repository.list(accountId) }); + set((state) => ({ slots: refreshLocalSlots(state.slots) })); }, updateSetting: (key, value) => set((state) => { const settings = { ...state.settings, [key]: value }; @@ -293,10 +369,10 @@ export const useFrontendStore = create((set, get) => ({ const { activeSlotId, accountId } = get(); if (!activeSlotId) return; repository.touch(activeSlotId); - set({ slots: repository.list(accountId) }); + set((state) => ({ slots: refreshLocalSlots(state.slots) })); }, recordBossVictory: (bossId, difficultySlug) => { - const { activeSlotId, accountId } = get(); + const { activeSlotId } = get(); if (!activeSlotId) return null; let awarded: BossRewardAward | null = null; repository.updateLocal(activeSlotId, (save) => { @@ -311,17 +387,31 @@ export const useFrontendStore = create((set, get) => ({ }; }); set((state) => ({ - slots: repository.list(accountId), + slots: refreshLocalSlots(state.slots), recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards, - notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.", + notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.", })); return awarded; }, + recordRoguelikeDefeat: (round) => { + const { activeSlotId } = get(); + if (!activeSlotId) return; + const updated = repository.updateLocal(activeSlotId, (save) => ({ + ...save, + stats: { + ...save.stats, + highestRoguelikeRound: highestRoguelikeRoundAfterDefeat(save.stats.highestRoguelikeRound, round), + }, + })); + if (!updated) return; + set((state) => ({ slots: refreshLocalSlots(state.slots) })); + }, clearRecentRewards: () => set({ recentRewards: [] }), clearNotice: () => set({ notice: "" }), })); export type FrontendSnapshot = Omit; export function getFrontendSnapshot(): FrontendSnapshot { const { + restoreSession: _restoreSession, signIn: _signIn, createAccount: _createAccount, continueOffline: _continueOffline, @@ -386,6 +478,7 @@ export function getFrontendSnapshot(): FrontendSnapshot { updateSetting: _updateSetting, touchActiveSave: _touchActiveSave, recordBossVictory: _recordBossVictory, + recordRoguelikeDefeat: _recordRoguelikeDefeat, clearRecentRewards: _clearRecentRewards, clearNotice: _clearNotice, ...snapshot diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 33673d6..74fddfa 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -5,7 +5,7 @@ import type { CollectionLog, MaterialStack } from "../game/progression/loot"; export type SaveSlotId = 1 | 2 | 3; export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "settings" | "mode" | "game"; -export type GameModeId = "roguelike-pve" | "dungeons" | "roguelike-pvp" | "stadium-pvp"; +export type GameModeId = "roguelike-pve" | "rogue-trials" | "dungeons" | "roguelike-pvp" | "stadium-pvp"; export interface CollectionDrop { id: string; @@ -41,6 +41,7 @@ export interface HunterStats { alliesSaved: number; healingDone: number; bossKills: Record; + highestRoguelikeRound: number; } export interface HealerProgress { diff --git a/src/game/bossVisuals.ts b/src/game/bossVisuals.ts new file mode 100644 index 0000000..64b501d --- /dev/null +++ b/src/game/bossVisuals.ts @@ -0,0 +1,88 @@ +import type { BossId } from "./types"; + +export type AlternateBossKind = Exclude; + +export interface AlternateBossConfig { + url: string; + scale: number; + idle: string; + move: string; + attack: string; + special: string; + death: string; + light: string; + rotationOffset: number; + floating?: boolean; +} + +export const BULL_URL = new URL("../assets/game/models/claudecraft/creatures/bull.glb", import.meta.url).href; + +const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href; +const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href; +const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href; +const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href; +const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href; + +const CLAUDE_BOSS_URLS: Record, string> = { + "stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href, + "cluckhorn-colossus": new URL("../assets/game/models/claudecraft/creatures/chicken_cow.glb", import.meta.url).href, + "ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href, + "riftclaw-demon": new URL("../assets/game/models/claudecraft/creatures/demonalt.glb", import.meta.url).href, + "tempestscale-dragon": new URL("../assets/game/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href, + emberfox: new URL("../assets/game/models/claudecraft/creatures/fox.glb", import.meta.url).href, + "mirelord-frog": new URL("../assets/game/models/claudecraft/creatures/frog.glb", import.meta.url).href, + "stonebreaker-giant": new URL("../assets/game/models/claudecraft/creatures/giant.glb", import.meta.url).href, + "glub-sovereign": new URL("../assets/game/models/claudecraft/creatures/glubevolved.glb", import.meta.url).href, + "scrapking-goblin": new URL("../assets/game/models/claudecraft/creatures/goblin.glb", import.meta.url).href, + "warcaller-orc": new URL("../assets/game/models/claudecraft/creatures/orc.glb", import.meta.url).href, + "tuskmaw-orc": new URL("../assets/game/models/claudecraft/creatures/orcenemy.glb", import.meta.url).href, + "broodfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href, + "silkfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href, + "thorncrown-stag": new URL("../assets/game/models/claudecraft/creatures/stag.glb", import.meta.url).href, + "sky-totem": new URL("../assets/game/models/claudecraft/creatures/tribal.glb", import.meta.url).href, + "razorcrest-raptor": new URL("../assets/game/models/claudecraft/creatures/velociraptor.glb", import.meta.url).href, + "bristlequake-boar": new URL("../assets/game/models/claudecraft/creatures/wild_boar.glb", import.meta.url).href, + "moonfang-wolf": new URL("../assets/game/models/claudecraft/creatures/wolf.glb", import.meta.url).href, + "frostmaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yeti.glb", import.meta.url).href, + "rimeclaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yetialt.glb", import.meta.url).href, +}; + +export const ALTERNATE_BOSS_CONFIG: Record = { + "sandglass-scorpion": { url: SANDGLASS_URL, scale: 0.7, idle: "Idle", move: "Burrow", attack: "Eruption", special: "Hourglass", death: "Death", light: "#e9b94f", rotationOffset: 0 }, + "cragclaw-crab": { url: CRAGCLAW_URL, scale: 1.2, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Bite_InPlace", death: "Death", light: "#49d5df", rotationOffset: 0 }, + "mournveil-ghost": { url: MOURNVEIL_URL, scale: 1.1, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#9d72ff", rotationOffset: 0, floating: true }, + "crownshard-golem": { url: CROWNSHARD_URL, scale: 1.15, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#e0bd45", rotationOffset: 0, floating: true }, + "crystal-bat-matriarch": { url: CRYSTAL_BAT_MATRIARCH_URL, scale: 0.828, idle: "Idle", move: "Swoop", attack: "SonicPulse", special: "MirrorShatter", death: "Death", light: "#8eeaff", rotationOffset: 0, floating: true }, + "stormwool-alpaca": { url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0 }, + "cluckhorn-colossus": { url: CLAUDE_BOSS_URLS["cluckhorn-colossus"], scale: 2.2, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#f0b85d", rotationOffset: 0 }, + "ashwing-demon": { url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, floating: true }, + "riftclaw-demon": { url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0 }, + "tempestscale-dragon": { url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, floating: true }, + emberfox: { url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0 }, + "mirelord-frog": { url: CLAUDE_BOSS_URLS["mirelord-frog"], scale: 1.4, idle: "Idle", move: "Run", attack: "Punch", special: "Jump", death: "Death", light: "#73c96b", rotationOffset: 0 }, + "stonebreaker-giant": { url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0 }, + "glub-sovereign": { url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, floating: true }, + "scrapking-goblin": { url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0 }, + "warcaller-orc": { url: CLAUDE_BOSS_URLS["warcaller-orc"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#e4533f", rotationOffset: 0 }, + "tuskmaw-orc": { url: CLAUDE_BOSS_URLS["tuskmaw-orc"], scale: 1.45, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#9eb25d", rotationOffset: 0 }, + "broodfang-spider": { url: CLAUDE_BOSS_URLS["broodfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#b56cff", rotationOffset: 0 }, + "silkfang-spider": { url: CLAUDE_BOSS_URLS["silkfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#9d68d8", rotationOffset: 0 }, + "thorncrown-stag": { url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0 }, + "sky-totem": { url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, floating: true }, + "razorcrest-raptor": { url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0 }, + "bristlequake-boar": { url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Death_AnimalArmature", light: "#d47b45", rotationOffset: 0 }, + "moonfang-wolf": { url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0 }, + "frostmaw-yeti": { url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0 }, + "rimeclaw-yeti": { url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0 }, +}; + +export function bossVisualUrl(bossId: BossId): string { + return bossId === "bulldrome" ? BULL_URL : ALTERNATE_BOSS_CONFIG[bossId].url; +} diff --git a/src/game/bosses/mechanicPool.test.ts b/src/game/bosses/mechanicPool.test.ts index 94deabb..dccbbf7 100644 --- a/src/game/bosses/mechanicPool.test.ts +++ b/src/game/bosses/mechanicPool.test.ts @@ -248,4 +248,14 @@ describe("shared boss mechanic pool", () => { expect(cleansed.motion.poolTelegraphs).toHaveLength(0); expect(cleansed.events[0].message).toContain("cleansing ward"); }); + + it("deals half-strength ramping Soul Siphon damage and caps the ramp", () => { + const result = advanceSoulSiphon(SOUL_SIPHON.tickInterval * 6 + 0.01, soulSiphonTelegraph()); + const aelia = result.party.find((member) => member.id === "aelia")!; + + expect(SOUL_SIPHON.tickDamage).toBe(3); + expect(SOUL_SIPHON.tickRamp).toBe(1); + expect(aelia.hp).toBe(freshParty()[0].hp - (3 + 4 + 5 + 6 + 7 + 7)); + expect(result.motion.poolTelegraphs[0].soulSiphon?.tickCount).toBe(6); + }); }); diff --git a/src/game/bosses/mechanicPool.ts b/src/game/bosses/mechanicPool.ts index df1dba4..e21d453 100644 --- a/src/game/bosses/mechanicPool.ts +++ b/src/game/bosses/mechanicPool.ts @@ -127,8 +127,8 @@ export const SOUL_SIPHON = { wardDistance: ARENA_RADIUS - 1.05, ghostSpeed: 2.6, tickInterval: 0.7, - tickDamage: 6, - tickRamp: 2, + tickDamage: 3, + tickRamp: 1, } as const; export const MEMORY_SYMBOLS: Record = { diff --git a/src/game/cameraOrbit.test.ts b/src/game/cameraOrbit.test.ts new file mode 100644 index 0000000..75b4dd7 --- /dev/null +++ b/src/game/cameraOrbit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_CAMERA_PITCH, + MAX_CAMERA_PITCH, + MIN_CAMERA_PITCH, + setCameraRelativeMovement, + updateCameraOrbit, +} from "./cameraOrbit"; + +describe("third-person camera orbit", () => { + it("rotates horizontal movement with camera yaw", () => { + const movement = { x: 0, z: 0 }; + + setCameraRelativeMovement(movement, 0, -1, 0); + expect(movement.x).toBeCloseTo(0); + expect(movement.z).toBeCloseTo(-1); + + setCameraRelativeMovement(movement, 0, -1, Math.PI / 2); + expect(movement.x).toBeCloseTo(-1); + expect(movement.z).toBeCloseTo(0); + }); + + it("applies look input and clamps vertical orbit", () => { + const orbit = { yaw: 0, pitch: DEFAULT_CAMERA_PITCH }; + + updateCameraOrbit(orbit, 1, 0, 1); + expect(orbit.yaw).toBeCloseTo(-2.25); + + updateCameraOrbit(orbit, 0, 1, 10); + expect(orbit.pitch).toBe(MAX_CAMERA_PITCH); + + updateCameraOrbit(orbit, 0, -1, 10); + expect(orbit.pitch).toBe(MIN_CAMERA_PITCH); + }); +}); diff --git a/src/game/cameraOrbit.ts b/src/game/cameraOrbit.ts new file mode 100644 index 0000000..77b78eb --- /dev/null +++ b/src/game/cameraOrbit.ts @@ -0,0 +1,49 @@ +export interface CameraOrbitState { + yaw: number; + pitch: number; +} + +export interface PlanarMovement { + x: number; + z: number; +} + +export const DEFAULT_CAMERA_YAW = 0; +export const DEFAULT_CAMERA_PITCH = Math.atan2(4.45, 7.7); +export const CAMERA_ORBIT_DISTANCE = Math.hypot(4.45, 7.7); +export const CAMERA_FOCUS_HEIGHT = 0.65; +export const CAMERA_LOOK_AHEAD = 2.8; +export const CAMERA_YAW_SPEED = 2.25; +export const CAMERA_PITCH_SPEED = 1.25; +export const MIN_CAMERA_PITCH = 0.24; +export const MAX_CAMERA_PITCH = 0.9; + +/** Updates one reusable orbit state without allocating in the render loop. */ +export function updateCameraOrbit( + orbit: CameraOrbitState, + lookX: number, + lookY: number, + deltaSeconds: number, +) { + orbit.yaw -= lookX * CAMERA_YAW_SPEED * deltaSeconds; + orbit.pitch = Math.min( + MAX_CAMERA_PITCH, + Math.max(MIN_CAMERA_PITCH, orbit.pitch + lookY * CAMERA_PITCH_SPEED * deltaSeconds), + ); + if (orbit.yaw > Math.PI || orbit.yaw < -Math.PI) { + orbit.yaw = Math.atan2(Math.sin(orbit.yaw), Math.cos(orbit.yaw)); + } +} + +/** Converts left-stick input into world movement relative to current camera yaw. */ +export function setCameraRelativeMovement( + output: PlanarMovement, + moveX: number, + moveY: number, + cameraYaw: number, +) { + const sinYaw = Math.sin(cameraYaw); + const cosYaw = Math.cos(cameraYaw); + output.x = moveX * cosYaw + moveY * sinYaw; + output.z = -moveX * sinYaw + moveY * cosYaw; +} diff --git a/src/game/healers.ts b/src/game/healers.ts index 7f488f7..91ec84f 100644 --- a/src/game/healers.ts +++ b/src/game/healers.ts @@ -73,7 +73,7 @@ const CLASS_INVENTORIES: Record = { priest: [ { id: "priest-censer", name: "Censer of First Light", slot: "Main Hand", rarity: "Rare", icon: "♰", stats: ["+12 Grace", "+8% Mend healing"], effect: "Mend restores 2 mana when it lands on an ally below 50% health.", equipped: true }, { id: "priest-vestment", name: "Ashwoven Vestment", slot: "Chest", rarity: "Uncommon", icon: "♜", stats: ["+18 Armor", "+6 Spirit"], effect: "Renew ticks have a 10% chance to extend Aegis Shield by 4 absorption.", equipped: true }, - { id: "priest-phial", name: "Moonwater Phial", slot: "Consumable", rarity: "Common", icon: "⚗", stats: ["Restores 40 mana"], effect: "Single use. Cannot be used during this prototype encounter.", equipped: false }, + { id: "priest-phial", name: "Moonwater Phial", slot: "Consumable", rarity: "Common", icon: "⚗", stats: ["Restores 40 mana"], effect: "Single use. Cannot be used during this encounter.", equipped: false }, { id: "priest-sigil", name: "Sigil of Quiet Resolve", slot: "Trinket", rarity: "Rare", icon: "◈", stats: ["+10% Purify range", "+5 Haste"], effect: "Purify grants its target 8 absorption when it removes Ember Brand.", equipped: false }, ], druid: [ diff --git a/src/game/performance.test.ts b/src/game/performance.test.ts index 1abe9b3..582bb2c 100644 --- a/src/game/performance.test.ts +++ b/src/game/performance.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest"; import { useGameStore } from "./store"; describe("runtime performance budgets", () => { - it("keeps ten minutes of dual-boss simulation bounded", () => { + it.each([ + { label: "dual-boss", bossIds: ["emberfox", "sandglass-scorpion"] as const, maxElapsedMs: 3_500 }, + { label: "Rogue Trials trio", bossIds: ["emberfox", "sandglass-scorpion", "tempestscale-dragon"] as const, maxElapsedMs: 5_000 }, + ])("keeps ten minutes of $label simulation bounded", ({ bossIds, maxElapsedMs }) => { const store = useGameStore.getState(); - store.configureHealer("priest", "Perf", [], ["emberfox", "sandglass-scorpion"]); + store.configureHealer("priest", "Perf", [], bossIds); store.startEncounter(); useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 1_000_000_000, maxHp: 1_000_000_000 }, @@ -36,6 +39,6 @@ describe("runtime performance budgets", () => { expect(maxHazards).toBeLessThanOrEqual(64); expect(maxDamageEvents).toBeLessThanOrEqual(24); expect(maxCombatLog).toBeLessThanOrEqual(12); - expect(elapsedMs).toBeLessThan(3_500); + expect(elapsedMs).toBeLessThan(maxElapsedMs); }); }); diff --git a/src/game/progression/gear.test.ts b/src/game/progression/gear.test.ts index 3579a2f..2d6bf34 100644 --- a/src/game/progression/gear.test.ts +++ b/src/game/progression/gear.test.ts @@ -3,6 +3,7 @@ import { createEncounterGearModifiers } from "./gearEffects"; import { GEAR_RECIPES, canAffordGearUpgrade, + canUpgradeGearSlot, createDefaultGearProgress, gearUpgradeCosts, upgradeGearSlot, @@ -46,6 +47,19 @@ describe("IWT2-style gear progression", () => { expect(progress.brann.slots.weapon.level).toBe(0); }); + it("reports only affordable non-max gear slots as upgradeable", () => { + const progress = createDefaultGearProgress(); + const recipe = GEAR_RECIPES.priest.weapon; + const drop = groupDrop(recipe.primaryGroupId, "initiate"); + const inventory: MaterialStack[] = [{ ...drop, quantity: 2 }]; + + expect(canUpgradeGearSlot(progress, inventory, "priest", "weapon")).toBe(true); + expect(canUpgradeGearSlot(progress, [], "priest", "weapon")).toBe(false); + + progress.priest.slots.weapon.level = 10; + expect(canUpgradeGearSlot(progress, inventory, "priest", "weapon")).toBe(false); + }); + it("projects rank bonuses without mutating saved progress", () => { const progress = createDefaultGearProgress(); progress.priest.slots.weapon.level = 10; diff --git a/src/game/progression/gear.ts b/src/game/progression/gear.ts index 0cab5a6..0ea02be 100644 --- a/src/game/progression/gear.ts +++ b/src/game/progression/gear.ts @@ -156,6 +156,17 @@ export function canAffordGearUpgrade(inventory: readonly MaterialStack[], costs: return costs.every((cost) => (inventory.find((item) => item.id === cost.itemId)?.quantity ?? 0) >= cost.quantity); } +export function canUpgradeGearSlot( + progress: GearProgress, + inventory: readonly MaterialStack[], + ownerId: GearOwnerId, + slotId: GearSlotId, +): boolean { + const currentLevel = progress[ownerId].slots[slotId].level; + return currentLevel < MAX_GEAR_LEVEL + && canAffordGearUpgrade(inventory, gearUpgradeCosts(ownerId, slotId, currentLevel)); +} + export function spendGearCosts(inventory: readonly MaterialStack[], costs: readonly GearUpgradeCost[]): MaterialStack[] { if (!canAffordGearUpgrade(inventory, costs)) { const missing = costs.find((cost) => (inventory.find((item) => item.id === cost.itemId)?.quantity ?? 0) < cost.quantity); diff --git a/src/game/progression/hunterStats.test.ts b/src/game/progression/hunterStats.test.ts new file mode 100644 index 0000000..d135f36 --- /dev/null +++ b/src/game/progression/hunterStats.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { highestRoguelikeRoundAfterDefeat } from "./hunterStats"; + +describe("roguelike hunter records", () => { + it("records the reached defeat round without lowering a previous best", () => { + expect(highestRoguelikeRoundAfterDefeat(0, 1)).toBe(1); + expect(highestRoguelikeRoundAfterDefeat(7, 12)).toBe(12); + expect(highestRoguelikeRoundAfterDefeat(12, 7)).toBe(12); + }); + + it("normalizes invalid and fractional round values", () => { + expect(highestRoguelikeRoundAfterDefeat(Number.NaN, Number.NaN)).toBe(1); + expect(highestRoguelikeRoundAfterDefeat(4.9, 8.9)).toBe(8); + }); +}); diff --git a/src/game/progression/hunterStats.ts b/src/game/progression/hunterStats.ts new file mode 100644 index 0000000..63eba3a --- /dev/null +++ b/src/game/progression/hunterStats.ts @@ -0,0 +1,5 @@ +export function highestRoguelikeRoundAfterDefeat(currentRecord: number, reachedRound: number): number { + const normalizedRecord = Math.max(0, Math.floor(Number(currentRecord) || 0)); + const normalizedRound = Math.max(1, Math.floor(Number(reachedRound) || 1)); + return Math.max(normalizedRecord, normalizedRound); +} diff --git a/src/game/progression/loot.test.ts b/src/game/progression/loot.test.ts index 3cad5b9..dce18be 100644 --- a/src/game/progression/loot.test.ts +++ b/src/game/progression/loot.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { BOSS_GROUPS } from "../bossCatalog"; -import { GROUP_DROP_TABLES, bossGroupDrop, createEmptyCollectionLog, groupDropQuantity, rollBossReward } from "./loot"; +import { BOSS_PET_DROPS, BOSS_PET_DROP_RATE, GROUP_DROP_TABLES, bossGroupDrop, createEmptyCollectionLog, groupDropQuantity, rollBossReward } from "./loot"; describe("group drop tables", () => { it("defines five shared drops for every mechanic group", () => { @@ -36,4 +36,20 @@ describe("group drop tables", () => { expect(second.collectionLog.dropsFound[second.award.drop.id]).toBe(5); expect(second.award.pet?.id).toBe("bulldrome-pet"); }); + + it("gives every boss pet an exact 1 in 500 independent roll", () => { + expect(BOSS_PET_DROP_RATE).toBe(1 / 500); + for (const pet of Object.values(BOSS_PET_DROPS)) { + expect(pet.dropRate).toBe(1 / 500); + expect(pet.chanceLabel).toBe("1 in 500"); + } + + const hitRolls = [1, 1 / 500 - Number.EPSILON]; + const hit = rollBossReward("bulldrome", "initiate", [], createEmptyCollectionLog(), () => hitRolls.shift() ?? 1); + expect(hit.award.pet?.id).toBe("bulldrome-pet"); + + const missRolls = [1, 1 / 500]; + const miss = rollBossReward("bulldrome", "initiate", [], createEmptyCollectionLog(), () => missRolls.shift() ?? 1); + expect(miss.award.pet).toBeNull(); + }); }); diff --git a/src/game/roguelike.test.ts b/src/game/roguelike.test.ts index 5c7165f..3f25c33 100644 --- a/src/game/roguelike.test.ts +++ b/src/game/roguelike.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { AVAILABLE_BOSS_IDS } from "./bossCatalog"; import { RUN_BUFF_ORDER, RUN_BUFFS, @@ -8,6 +9,8 @@ import { formatRunBuffEffect, increaseRunBuffRank, selectRandomBossPair, + selectRogueTrialsBosses, + selectUnseenBosses, selectRunBuffDraft, } from "./roguelike"; import type { RunBuffRanks } from "./types"; @@ -75,4 +78,26 @@ describe("roguelike progression", () => { expect(pair).not.toContain("bulldrome"); expect(pair).not.toContain("broodfang-spider"); }); + + it("selects three distinct unseen bosses for Rogue Trials round five", () => { + const seen = [ + "bulldrome", + "sandglass-scorpion", + "cragclaw-crab", + "mournveil-ghost", + "crownshard-golem", + "crystal-bat-matriarch", + "stormwool-alpaca", + "cluckhorn-colossus", + ] as const; + const trio = selectRogueTrialsBosses(5, seen, () => 0); + + expect(trio).toEqual(["ashwing-demon", "riftclaw-demon", "tempestscale-dragon"]); + expect(new Set(trio)).toHaveLength(3); + expect(trio.every((bossId) => !seen.includes(bossId as typeof seen[number]))).toBe(true); + }); + + it("fails instead of silently reusing seen bosses when unseen pool is too small", () => { + expect(() => selectUnseenBosses(3, AVAILABLE_BOSS_IDS.slice(0, -2))).toThrow(/Cannot select 3 unseen bosses/); + }); }); diff --git a/src/game/roguelike.ts b/src/game/roguelike.ts index 9dfca52..8958741 100644 --- a/src/game/roguelike.ts +++ b/src/game/roguelike.ts @@ -204,6 +204,39 @@ export function bossHealthMultiplier(round: number) { return 1 + Math.max(0, round - 1) * 0.1; } +export const ROGUE_TRIALS_TRIO_ROUND = 5; + +export function selectUnseenBosses( + count: number, + seenBossIds: readonly BossId[] = [], + random: () => number = Math.random, +): BossId[] { + const seen = new Set(seenBossIds); + const pool = AVAILABLE_BOSS_IDS.filter((bossId) => !seen.has(bossId)); + const requestedCount = Math.max(0, Math.floor(count)); + if (pool.length < requestedCount) { + throw new Error(`Cannot select ${requestedCount} unseen bosses from ${pool.length} remaining bosses.`); + } + const bosses: BossId[] = []; + while (bosses.length < requestedCount) { + const sample = random(); + const randomValue = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999, sample)) : 0; + const index = Math.floor(randomValue * pool.length); + bosses.push(pool[index]); + pool.splice(index, 1); + } + return bosses; +} + +export function selectRogueTrialsBosses( + round: number, + seenBossIds: readonly BossId[], + random: () => number = Math.random, +): BossId[] { + const count = round === ROGUE_TRIALS_TRIO_ROUND ? 3 : 2; + return selectUnseenBosses(count, seenBossIds, random); +} + export function selectRandomBossPair( excludedBossIds: readonly BossId[] = [], random: () => number = Math.random, diff --git a/src/game/store.test.ts b/src/game/store.test.ts index 0a9bd9c..d06fbc0 100644 --- a/src/game/store.test.ts +++ b/src/game/store.test.ts @@ -1,7 +1,7 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { BULL_CHARGE } from "./bossMechanics"; import { distance, pointToSegmentDistance } from "./geometry"; -import { barrierProtects, useGameStore } from "./store"; +import { RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store"; import { createClassInventory, HEALER_CLASSES } from "./healers"; import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool"; import { ARENA_CENTER, isInsideArena } from "./arena"; @@ -581,6 +581,7 @@ describe("Roguelike rounds", () => { expect(useGameStore.getState().time).toBe(intermissionTime); const chosenBuffId = useGameStore.getState().draftBuffIds[0]!; expect(chosenBuffId).toBeDefined(); + useGameStore.setState({ runBuffInputUnlockAt: 0 }); expect(useGameStore.getState().chooseRunBuff(chosenBuffId)).toBe(true); const roundTwo = useGameStore.getState(); @@ -599,6 +600,34 @@ describe("Roguelike rounds", () => { expect(useGameStore.getState().round).toBe(1); }); + it("ignores all buff-selection input for 2.5 seconds after the bosses fall", () => { + const defeatedAt = 1_000_000; + const now = vi.spyOn(Date, "now").mockReturnValue(defeatedAt); + useGameStore.setState((state) => ({ + boss: { ...state.boss, hp: 0 }, + additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })), + })); + + useGameStore.getState().tick(0.05); + const lockedState = useGameStore.getState(); + const initialSelection = lockedState.selectedRunBuffId!; + const alternateSelection = lockedState.draftBuffIds.find((buffId) => buffId !== initialSelection)!; + expect(lockedState.runBuffInputUnlockAt).toBe(defeatedAt + RUN_BUFF_INPUT_LOCK_MS); + + useGameStore.getState().setSelectedRunBuff(alternateSelection); + expect(useGameStore.getState().selectedRunBuffId).toBe(initialSelection); + expect(useGameStore.getState().chooseRunBuff(initialSelection)).toBe(false); + + now.mockReturnValue(defeatedAt + RUN_BUFF_INPUT_LOCK_MS - 1); + expect(useGameStore.getState().chooseRunBuff(initialSelection)).toBe(false); + + now.mockReturnValue(defeatedAt + RUN_BUFF_INPUT_LOCK_MS); + useGameStore.getState().setSelectedRunBuff(alternateSelection); + expect(useGameStore.getState().selectedRunBuffId).toBe(alternateSelection); + expect(useGameStore.getState().chooseRunBuff(alternateSelection)).toBe(true); + now.mockRestore(); + }); + it("offers explicit continuation after every buff reaches maximum rank", () => { const maxedRanks = Object.fromEntries(RUN_BUFF_ORDER.map((id) => [id, RUN_BUFFS[id].maxRank])) as RunBuffRanks; useGameStore.setState({ @@ -607,8 +636,11 @@ describe("Roguelike rounds", () => { runModifiers: compileRunModifiers(maxedRanks), draftBuffIds: [], selectedRunBuffId: null, + runBuffInputUnlockAt: Date.now() + RUN_BUFF_INPUT_LOCK_MS, }); + expect(useGameStore.getState().continueRoguelikeRound()).toBe(false); + useGameStore.setState({ runBuffInputUnlockAt: 0 }); expect(useGameStore.getState().continueRoguelikeRound()).toBe(true); expect(useGameStore.getState().phase).toBe("combat"); expect(useGameStore.getState().round).toBe(2); @@ -618,6 +650,82 @@ describe("Roguelike rounds", () => { }); }); +describe("Rogue Trials", () => { + beforeEach(() => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + ["bulldrome", "broodfang-spider"], + "rogue-trials", + ); + useGameStore.getState().startEncounter(); + }); + + it("builds toward an unseen three-boss finale on round five", () => { + for (let expectedRound = 2; expectedRound <= 5; expectedRound += 1) { + const before = useGameStore.getState(); + const seenBefore = [...before.seenBossIds]; + useGameStore.setState((state) => ({ + phase: "intermission", + runBuffInputUnlockAt: 0, + boss: { ...state.boss, hp: 0 }, + additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })), + })); + + const buffId = useGameStore.getState().draftBuffIds[0]!; + expect(useGameStore.getState().chooseRunBuff(buffId)).toBe(true); + const next = useGameStore.getState(); + const nextBossIds = [next.boss.id, ...next.additionalBosses.map((entry) => entry.boss.id)]; + expect(next.round).toBe(expectedRound); + expect(nextBossIds).toHaveLength(expectedRound === 5 ? 3 : 2); + expect(nextBossIds.every((bossId) => !seenBefore.includes(bossId))).toBe(true); + } + + const finale = useGameStore.getState(); + expect(finale.seenBossIds).toHaveLength(11); + expect(finale.additionalBosses).toHaveLength(2); + }); + + it("ends in victory after all three finale bosses fall", () => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + ["ashwing-demon", "riftclaw-demon", "tempestscale-dragon"], + "rogue-trials", + ); + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + round: 5, + phase: "combat", + boss: { ...state.boss, hp: 0 }, + additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })), + })); + + useGameStore.getState().tick(0.05); + expect(useGameStore.getState().phase).toBe("victory"); + }); + + it("restarts a completed trial with a fresh two-boss first round", () => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + ["ashwing-demon", "riftclaw-demon", "tempestscale-dragon"], + "rogue-trials", + ); + useGameStore.setState({ round: 5, phase: "victory" }); + + useGameStore.getState().restart(); + const restarted = useGameStore.getState(); + expect(restarted.round).toBe(1); + expect(restarted.phase).toBe("briefing"); + expect(restarted.additionalBosses).toHaveLength(1); + expect(restarted.seenBossIds).toHaveLength(2); + }); +}); + describe("shared arena boundary", () => { beforeEach(() => { useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest")); diff --git a/src/game/store.ts b/src/game/store.ts index c9389ed..7d2e5e3 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -24,6 +24,8 @@ import { runAbilityManaCost, selectRunBuffDraft, selectRandomBossPair, + selectRogueTrialsBosses, + ROGUE_TRIALS_TRIO_ROUND, type CompiledRunModifiers, } from "./roguelike"; import { createDefaultGearProgress, type GearProgress } from "./progression/gear"; @@ -72,9 +74,11 @@ export interface GameState { phase: GamePhase; runMode: RunMode; round: number; + seenBossIds: BossId[]; runBuffRanks: RunBuffRanks; draftBuffIds: RunBuffId[]; selectedRunBuffId: RunBuffId | null; + runBuffInputUnlockAt: number; passiveRunBuffId: RunBuffId | null; runModifiers: CompiledRunModifiers; healingMultiplier: number; @@ -131,19 +135,20 @@ const emptyCooldowns = (): Record => ({ }); export const GLOBAL_COOLDOWN_SECONDS = 0.5; +export const RUN_BUFF_INPUT_LOCK_MS = 2_500; export const BARRIER_RADIUS = 3; export const BARRIER_DAMAGE_REDUCTION = 0.3; const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => { const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds]; - const unique = requested.filter((bossId, index) => requested.indexOf(bossId) === index).slice(0, 2); + const unique = requested.filter((bossId, index) => requested.indexOf(bossId) === index).slice(0, 3); return unique.length ? unique : ["bulldrome"]; }; function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState { const motion = cloneMotion(createBossMotionState(bossId)); - const offset = count > 1 ? (index === 0 ? -2.65 : 2.65) : 0; + const offset = count === 3 ? [-3.7, 0, 3.7][index] : count === 2 ? [-2.65, 2.65][index] : 0; motion.formationOffsetX = offset; motion.position[0] += offset; motion.chargeStart[0] += offset; @@ -280,6 +285,7 @@ function initialState( runBuffRanks: RunBuffRanks = {}, gearProgress: GearProgress = createDefaultGearProgress(), requestedDifficultySlug: DifficultySlug = "initiate", + seenBossIds: readonly BossId[] = [], ) { const difficultySlug = normalizeDifficultySlug(requestedDifficultySlug); const difficulty = DIFFICULTY_BY_SLUG[difficultySlug]; @@ -294,7 +300,7 @@ function initialState( const gearModifiers = createEncounterGearModifiers(gearProgress, healerClassId); const passiveInfusionId = passiveInfusionUnlocked(gearProgress) ? gearProgress[healerClassId].passiveInfusionId : null; const runModifiers = compileRunModifiers(runBuffRanks, passiveInfusionId); - const draftBuffIds = runMode === "roguelike" ? selectRunBuffDraft(runBuffRanks, passiveInfusionId) : []; + const draftBuffIds = runMode !== "encounter" ? selectRunBuffDraft(runBuffRanks, passiveInfusionId) : []; const party = applyGearHealth(freshParty(healerClassId, playerName), gearModifiers); const maxMana = 100; return { @@ -306,9 +312,11 @@ function initialState( phase: "briefing" as GamePhase, runMode, round, + seenBossIds: [...new Set([...seenBossIds, ...bossIds])], runBuffRanks: { ...runBuffRanks }, draftBuffIds, selectedRunBuffId: draftBuffIds[0] ?? null, + runBuffInputUnlockAt: 0, passiveRunBuffId: passiveInfusionId, runModifiers, healingMultiplier: gearModifiers.aelia.healingPower, @@ -348,10 +356,10 @@ export const useGameStore = create((set, get) => ({ }, startEncounter: () => { - const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, round, runBuffRanks, gearProgress, difficultySlug } = get(); + const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, round, runBuffRanks, gearProgress, difficultySlug, seenBossIds } = get(); const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]; set({ - ...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffRanks, gearProgress, difficultySlug), + ...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffRanks, gearProgress, difficultySlug, seenBossIds), phase: "combat", activeTab: "combat", combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }], @@ -360,7 +368,10 @@ export const useGameStore = create((set, get) => ({ restart: () => { const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug } = get(); - set(initialState(healerClassId, playerName, inventory, [boss.id, ...additionalBosses.map((entry) => entry.boss.id)], runMode, 1, {}, gearProgress, difficultySlug)); + const bossIds = runMode === "rogue-trials" + ? selectRandomBossPair() + : [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]; + set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug)); }, selectMember: (selectedMemberId) => set({ selectedMemberId }), @@ -380,20 +391,24 @@ export const useGameStore = create((set, get) => ({ togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })), setPauseSelection: (pauseSelection) => set({ pauseSelection }), setSelectedRunBuff: (selectedRunBuffId) => set((state) => - state.phase === "intermission" && state.draftBuffIds.includes(selectedRunBuffId) + state.phase === "intermission" + && !isRunBuffInputLocked(state) + && state.draftBuffIds.includes(selectedRunBuffId) ? { selectedRunBuffId } : state ), chooseRunBuff: (buffId) => { const state = get(); - if (state.phase !== "intermission" || !state.draftBuffIds.includes(buffId)) return false; + if (state.phase !== "intermission" || isRunBuffInputLocked(state) || !state.draftBuffIds.includes(buffId)) return false; const runBuffRanks = increaseRunBuffRank(state.runBuffRanks, buffId); const round = state.round + 1; const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)]; - const bossIds = selectRandomBossPair(previousBossIds); + const bossIds = state.runMode === "rogue-trials" + ? selectRogueTrialsBosses(round, state.seenBossIds) + : selectRandomBossPair(previousBossIds); const abilityName = HEALER_CLASSES[state.healerClassId].abilities[RUN_BUFFS[buffId].abilityId].name; set({ - ...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, "roguelike", round, runBuffRanks, state.gearProgress, state.difficultySlug), + ...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, state.runMode, round, runBuffRanks, state.gearProgress, state.difficultySlug, state.seenBossIds), phase: "combat", activeTab: "combat", combatLog: [{ @@ -407,12 +422,14 @@ export const useGameStore = create((set, get) => ({ }, continueRoguelikeRound: () => { const state = get(); - if (state.phase !== "intermission" || state.draftBuffIds.length > 0) return false; + if (state.phase !== "intermission" || isRunBuffInputLocked(state) || state.draftBuffIds.length > 0) return false; const round = state.round + 1; const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)]; - const bossIds = selectRandomBossPair(previousBossIds); + const bossIds = state.runMode === "rogue-trials" + ? selectRogueTrialsBosses(round, state.seenBossIds) + : selectRandomBossPair(previousBossIds); set({ - ...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, "roguelike", round, state.runBuffRanks, state.gearProgress, state.difficultySlug), + ...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, state.runMode, round, state.runBuffRanks, state.gearProgress, state.difficultySlug, state.seenBossIds), phase: "combat", activeTab: "combat", combatLog: [{ @@ -721,8 +738,11 @@ export const useGameStore = create((set, get) => ({ const tank = party.find((member) => member.id === "brann")!; const healer = party.find((member) => member.id === "aelia")!; let phase: GamePhase = state.phase; + let runBuffInputUnlockAt = state.runBuffInputUnlockAt; if (encounterBosses.every((entry) => entry.boss.hp <= 0)) { - phase = state.runMode === "roguelike" ? "intermission" : "victory"; + const rogueTrialsComplete = state.runMode === "rogue-trials" && state.round === ROGUE_TRIALS_TRIO_ROUND; + phase = state.runMode !== "encounter" && !rogueTrialsComplete ? "intermission" : "victory"; + if (phase === "intermission") runBuffInputUnlockAt = Date.now() + RUN_BUFF_INPUT_LOCK_MS; combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good"); } else if (tank.hp <= 0 || healer.hp <= 0) { phase = "defeat"; @@ -739,6 +759,7 @@ export const useGameStore = create((set, get) => ({ partyPositions, bossMotion, phase, + runBuffInputUnlockAt, mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)), activeCast, combatLog, @@ -794,6 +815,13 @@ export function abilityRemaining(abilityId: AbilityId, time: number, cooldowns: return Math.max(0, cooldowns[abilityId] - time); } +export function isRunBuffInputLocked( + state: Pick, + now = Date.now(), +) { + return state.phase === "intermission" && now < state.runBuffInputUnlockAt; +} + export { upcomingMechanic }; export function upcomingEncounterMechanic(state: Pick) { diff --git a/src/game/types.ts b/src/game/types.ts index e23478f..f7692be 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -61,7 +61,7 @@ export type BossMechanicId = | "soul-siphon"; export type BossAnimationCue = "idle" | "move" | "attack" | "special"; export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat"; -export type RunMode = "encounter" | "roguelike"; +export type RunMode = "encounter" | "roguelike" | "rogue-trials"; export type RunBuffId = | "mend-echo" | "mend-efficiency" diff --git a/src/game/useGameLoop.ts b/src/game/useGameLoop.ts index db8dbf5..2237372 100644 --- a/src/game/useGameLoop.ts +++ b/src/game/useGameLoop.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { subscribeControllerToken } from "../input/controller"; import { ABILITY_ORDER } from "./data"; -import { useGameStore } from "./store"; +import { isRunBuffInputLocked, useGameStore } from "./store"; import type { AbilityId } from "./types"; function cycleRunBuff(direction: 1 | -1) { @@ -43,6 +43,7 @@ export function useActionBindings(enabled = true, onExit?: () => void) { } if (store.phase === "intermission") { if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault(); + if (isRunBuffInputLocked(store)) return; if (key === "arrowleft" || key === "arrowup") cycleRunBuff(-1); if (key === "arrowright" || key === "arrowdown") cycleRunBuff(1); if (key === "enter") { @@ -99,6 +100,7 @@ export function useActionBindings(enabled = true, onExit?: () => void) { return; } if (store.phase === "intermission") { + if (isRunBuffInputLocked(store)) return; if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1); if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) cycleRunBuff(1); if (!repeat && token === "Button0") { diff --git a/src/input/controller.test.ts b/src/input/controller.test.ts index 5d6f1d0..c12d236 100644 --- a/src/input/controller.test.ts +++ b/src/input/controller.test.ts @@ -10,18 +10,18 @@ describe("controller movement normalization", () => { beforeEach(() => resetControllerState()); it("ignores dead-zone noise and quantizes analog jitter", () => { - const updates: Array<{ x: number; y: number }> = []; + const updates: Array<{ moveX: number; moveY: number; lookX: number; lookY: number }> = []; const unsubscribe = subscribeControllerMovement((movement) => { - updates.push({ x: movement.x, y: movement.y }); + updates.push({ ...movement }); }); - setExternalControllerMovement({ x: 0.08, y: -0.1 }); + setExternalControllerMovement({ moveX: 0.08, moveY: -0.1, lookX: 0.11, lookY: -0.02 }); expect(updates).toHaveLength(0); - setExternalControllerMovement({ x: 0.5001, y: -0.5001 }); - setExternalControllerMovement({ x: 0.5002, y: -0.5002 }); - expect(updates).toEqual([{ x: 0.5, y: -0.5 }]); - expect(getControllerMovement()).toEqual({ x: 0.5, y: -0.5 }); + setExternalControllerMovement({ moveX: 0.5001, moveY: -0.5001, lookX: 0.2501, lookY: -0.2501 }); + setExternalControllerMovement({ moveX: 0.5002, moveY: -0.5002, lookX: 0.2502, lookY: -0.2502 }); + expect(updates).toEqual([{ moveX: 0.5, moveY: -0.5, lookX: 0.25, lookY: -0.25 }]); + expect(getControllerMovement()).toEqual({ moveX: 0.5, moveY: -0.5, lookX: 0.25, lookY: -0.25 }); unsubscribe(); }); diff --git a/src/input/controller.ts b/src/input/controller.ts index 6bc46d8..c82db2b 100644 --- a/src/input/controller.ts +++ b/src/input/controller.ts @@ -6,8 +6,10 @@ export interface ControllerTokenEvent { } export interface ControllerMovement { - x: number; - y: number; + moveX: number; + moveY: number; + lookX: number; + lookY: number; } type TokenListener = (event: ControllerTokenEvent) => void; @@ -30,7 +32,7 @@ const repeatAt = new Map(); const lastNativeTokenAt = new Map(); let previousTokens = new Set(); let currentTokens = new Set(); -let movement: ControllerMovement = { x: 0, y: 0 }; +let movement: ControllerMovement = { moveX: 0, moveY: 0, lookX: 0, lookY: 0 }; let stopService: (() => void) | null = null; let dispatchDepth = 0; @@ -97,14 +99,25 @@ export function getControllerMovement(): Readonly { } export function setExternalControllerMovement(next: ControllerMovement) { - setExternalControllerAxes(next.x, next.y); + setExternalControllerAxes(next.moveX, next.moveY, next.lookX, next.lookY); } -function setExternalControllerAxes(nextX: number, nextY: number) { - const x = Math.abs(nextX) >= 0.12 ? Math.round(nextX * MOVEMENT_AXIS_STEPS) / MOVEMENT_AXIS_STEPS : 0; - const y = Math.abs(nextY) >= 0.12 ? Math.round(nextY * MOVEMENT_AXIS_STEPS) / MOVEMENT_AXIS_STEPS : 0; - if (x === movement.x && y === movement.y) return; - movement = { x, y }; +function normalizedAxis(value: number) { + return Math.abs(value) >= 0.12 ? Math.round(value * MOVEMENT_AXIS_STEPS) / MOVEMENT_AXIS_STEPS : 0; +} + +function setExternalControllerAxes(nextMoveX: number, nextMoveY: number, nextLookX: number, nextLookY: number) { + const moveX = normalizedAxis(nextMoveX); + const moveY = normalizedAxis(nextMoveY); + const lookX = normalizedAxis(nextLookX); + const lookY = normalizedAxis(nextLookY); + if ( + moveX === movement.moveX + && moveY === movement.moveY + && lookX === movement.lookX + && lookY === movement.lookY + ) return; + movement = { moveX, moveY, lookX, lookY }; for (const listener of movementListeners) listener(movement); } @@ -113,7 +126,7 @@ export function resetControllerState() { currentTokens.clear(); repeatAt.clear(); lastNativeTokenAt.clear(); - setExternalControllerAxes(0, 0); + setExternalControllerAxes(0, 0, 0, 0); } export function startControllerInput() { @@ -153,7 +166,12 @@ export function startControllerInput() { hadConnectedGamepad = false; } else { hadConnectedGamepad = true; - setExternalControllerAxes(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0); + setExternalControllerAxes( + gamepad.axes[0] ?? 0, + gamepad.axes[1] ?? 0, + gamepad.axes[2] ?? 0, + gamepad.axes[3] ?? 0, + ); tokensFor(gamepad, currentTokens); for (const token of currentTokens) { const pressed = !previousTokens.has(token); diff --git a/src/platform/BottomDisplayApp.tsx b/src/platform/BottomDisplayApp.tsx index 976604b..a317dd9 100644 --- a/src/platform/BottomDisplayApp.tsx +++ b/src/platform/BottomDisplayApp.tsx @@ -77,11 +77,11 @@ export function BottomDisplayApp() { const sentControllerIds = new Set(); let controllerSequence = 0; let receivingControllerEcho = false; - let latestMovement = { x: 0, y: 0 }; + let latestMovement = { moveX: 0, moveY: 0, lookX: 0, lookY: 0 }; const movementPublisher = createRateLimitedPublisher(() => { channel.postMessage({ type: "controller-motion", - movement: { x: latestMovement.x, y: latestMovement.y }, + movement: { ...latestMovement }, } satisfies DualScreenMessage); }, CONTROLLER_MOTION_SYNC_INTERVAL_MS); const announceReady = () => channel.postMessage({ type: "companion-ready" } satisfies DualScreenMessage); @@ -94,6 +94,7 @@ export function BottomDisplayApp() { if (!isControllerDispatchActive()) channel.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage); }; useFrontendStore.setState({ + restoreSession: () => Promise.resolve(false), signIn: (username, password) => { postFrontend({ name: "signIn", username, password }); return Promise.resolve(false); @@ -113,8 +114,14 @@ export function BottomDisplayApp() { playSlot: (slotId) => postFrontend({ name: "playSlot", slotId }), deleteSlot: (slotId) => postFrontend({ name: "deleteSlot", slotId }), copySlot: (sourceId, targetId) => postFrontend({ name: "copySlot", sourceId, targetId }), - uploadSlot: (slotId) => postFrontend({ name: "uploadSlot", slotId }), - downloadSlot: (slotId) => postFrontend({ name: "downloadSlot", slotId }), + uploadSlot: (slotId) => { + postFrontend({ name: "uploadSlot", slotId }); + return Promise.resolve(); + }, + downloadSlot: (slotId) => { + postFrontend({ name: "downloadSlot", slotId }); + return Promise.resolve(); + }, selectMode: (mode) => postFrontend({ name: "selectMode", mode }), selectBoss: (bossId) => postFrontend({ name: "selectBoss", bossId }), selectDifficulty: (difficultySlug) => postFrontend({ name: "selectDifficulty", difficultySlug }), diff --git a/src/platform/useThorDualScreen.ts b/src/platform/useThorDualScreen.ts index db4ca2f..9298100 100644 --- a/src/platform/useThorDualScreen.ts +++ b/src/platform/useThorDualScreen.ts @@ -92,7 +92,7 @@ export function useAuthoritativeDualScreenSync() { companionReady = false; lastPublishedGame = undefined; gamePublisher.cancel(); - setExternalControllerMovement({ x: 0, y: 0 }); + setExternalControllerMovement({ moveX: 0, moveY: 0, lookX: 0, lookY: 0 }); } else if (event.data.type === "controller-token") { receivingRelayedToken = true; emitControllerToken(event.data.event); diff --git a/src/styles.css b/src/styles.css index 44ddfdd..1121ec4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -52,13 +52,13 @@ button:focus-visible { outline-offset: 2px; } -.prototype-shell { +.app-shell { width: 100%; min-height: 100vh; padding: 22px 24px 70px; } -.prototype-header { +.app-header { width: min(var(--thor-main-css-width), 100%); margin: 0 auto 16px; display: flex; @@ -69,31 +69,31 @@ button:focus-visible { letter-spacing: 0.13em; } -.prototype-header div { +.app-header div { display: grid; } -.prototype-header span, -.prototype-header p { +.app-header span, +.app-header p { font-size: 11px; font-weight: 600; margin: 0; } -.prototype-header strong { +.app-header strong { color: var(--ink); font-family: "Cinzel", Georgia, serif; font-size: 19px; letter-spacing: 0.05em; } -.prototype-header p { +.app-header p { display: flex; align-items: center; gap: 8px; } -.prototype-header p i { +.app-header p i { width: 3px; height: 3px; border-radius: 50%; @@ -366,10 +366,13 @@ button:focus-visible { transform: translateX(-50%); } -.boss-bar-wrap.is-dual { top: 2.2%; width: 39%; display: grid; gap: 3px; } -.boss-bar-wrap.is-dual .boss-name strong { font-size: 10px; } -.boss-bar-wrap.is-dual .boss-name { font-size: 6px; } -.boss-bar-wrap.is-dual .boss-bar { height: 6px; margin-top: 2px; padding: 1px; } +.boss-bar-wrap.is-multi { top: 2.2%; width: 39%; display: grid; gap: 3px; } +.boss-bar-wrap.is-multi .boss-name strong { font-size: 10px; } +.boss-bar-wrap.is-multi .boss-name { font-size: 6px; } +.boss-bar-wrap.is-multi .boss-bar { height: 6px; margin-top: 2px; padding: 1px; } +.boss-bar-wrap.is-trio { top: 1.1%; width: 42%; gap: 2px; } +.boss-bar-wrap.is-trio .boss-name strong { font-size: 8px; } +.boss-bar-wrap.is-trio .boss-bar { height: 5px; margin-top: 1px; } .boss-name { display: grid; @@ -1134,6 +1137,8 @@ button:focus-visible { text-align: left; } .buff-choice-grid button.is-controller-focused { outline: 2px solid var(--gold-strong); outline-offset: 2px; transform: translateY(-2px); } +.buff-draft.is-input-locked .buff-choice-grid button { cursor: default; filter: saturate(0.55) brightness(0.78); } +.buff-draft.is-input-locked .buff-choice-grid button.is-controller-focused { outline-color: #60726b; transform: none; } .buff-choice-grid button > i { grid-row: 1 / 3; width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid var(--buff-accent); color: var(--buff-accent); font-family: "Cinzel", serif; font-size: 16px; font-style: normal; } .buff-choice-grid button > span { min-width: 0; display: grid; } .buff-choice-grid button small { color: var(--buff-accent); font-size: 6px; letter-spacing: 0.1em; text-transform: uppercase; } @@ -1278,9 +1283,9 @@ button:focus-visible { } @media (max-width: 760px) { - .prototype-shell { padding: 10px 7px 45px; } - .prototype-header { padding: 0 4px; } - .prototype-header p { display: none; } + .app-shell { padding: 10px 7px 45px; } + .app-header { padding: 0 4px; } + .app-header p { display: none; } .screen-label span { font-size: 8px; } .screen-label small { display: none; } .display { box-shadow: 0 0 0 3px #080d0c, 0 0 0 4px rgba(152, 181, 171, 0.1), 0 12px 35px rgba(0,0,0,0.55); } @@ -1324,7 +1329,7 @@ button:focus-visible { .native-platform, .native-platform body, .native-platform #root, -.native-platform .prototype-shell, +.native-platform .app-shell, .native-platform .device-frame { width: 100%; height: 100%; @@ -1332,11 +1337,11 @@ button:focus-visible { overflow: hidden; } -.native-platform .prototype-shell { +.native-platform .app-shell { padding: 0; } -.native-platform .prototype-header, +.native-platform .app-header, .native-platform .screen-label, .native-platform .hinge { display: none; @@ -1753,7 +1758,7 @@ button:focus-visible { .home-title { padding: 17px 2px 12px; } .home-title span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; } .home-title h1 { margin: 2px 0 0; font-family: "Cinzel", serif; font-size: 25px; font-weight: 500; } -.mode-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, 84px); gap: 10px; } +.mode-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, 78px); gap: 10px; } .mode-card { position: relative; display: grid; grid-template-columns: 54px 1fr 17px; align-items: center; gap: 12px; padding: 13px; overflow: hidden; text-align: left; } .mode-card::after { position: absolute; inset: 0; content: ""; background: linear-gradient(110deg, rgba(69,153,131,0.13), transparent 60%); pointer-events: none; } .mode-card.is-wide { grid-row: 1 / 3; } @@ -1811,6 +1816,10 @@ button:focus-visible { /* Profile */ .profile-surface { padding: 0 30px; } +.profile-header { grid-template-columns: 190px minmax(0, 1fr) auto auto; } +.profile-view-tabs { display: flex; gap: 4px; } +.profile-view-tabs button { min-height: 28px; padding: 5px 9px; color: #80948c; font-size: 8px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; } +.profile-view-tabs button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: linear-gradient(180deg, rgba(102,82,30,.28), rgba(37,29,12,.18)); } .collection-heading { display: flex; align-items: end; justify-content: space-between; padding: 16px 2px 10px; } .collection-heading > span { display: grid; } .collection-heading small { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; } @@ -1832,18 +1841,70 @@ button:focus-visible { .collection-note > span { display: flex; align-items: baseline; gap: 8px; } .collection-note strong { font-size: 9px; } .collection-note small { color: #758a82; font-size: 8px; } +.trophy-heading { padding-top: 11px; } +.trophy-case { display: grid; gap: 10px; } +.trophy-count-1 { grid-template-columns: 1fr; } +.trophy-count-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.trophy-count-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.trophy-count-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.trophy-count-1 .boss-trophy { width: min(280px, 100%); justify-self: center; } +.boss-trophy { --boss-accent: var(--gold); position: relative; min-width: 0; height: 326px; overflow: hidden; border: 1px solid #43574f; border-top: 2px solid #6e8079; background: radial-gradient(circle at 50% 18%, color-mix(in srgb, var(--boss-accent) 18%, transparent), transparent 47%), linear-gradient(180deg, #0b1815, #07100e); box-shadow: inset 0 0 30px rgba(0,0,0,.55); } +.boss-trophy::before { content: ""; position: absolute; inset: 7px; z-index: 2; border: 1px solid rgba(198,218,210,.1); pointer-events: none; } +.boss-trophy.is-owned { border-color: color-mix(in srgb, var(--boss-accent) 68%, #d6bf79); box-shadow: inset 0 0 30px rgba(0,0,0,.42), 0 0 15px color-mix(in srgb, var(--boss-accent) 24%, transparent); } +.boss-trophy.is-locked { border-color: #3b4844; background: linear-gradient(180deg, #111715, #080d0c); } +.trophy-portrait { position: relative; height: 224px; overflow: hidden; border-bottom: 1px solid rgba(180,207,197,.16); background: radial-gradient(ellipse at 50% 68%, color-mix(in srgb, var(--boss-accent) 18%, transparent), transparent 48%); } +.trophy-portrait canvas { position: relative; z-index: 1; display: block; width: 100% !important; height: 100% !important; pointer-events: none; } +.trophy-portrait > span { position: absolute; right: 12px; bottom: 7px; z-index: 2; color: color-mix(in srgb, var(--boss-accent) 78%, #fff); font: 19px "Cinzel", serif; opacity: .72; } +.boss-trophy.is-locked .trophy-portrait canvas { filter: grayscale(1) brightness(.36) contrast(1.18); opacity: .7; } +.boss-trophy.is-locked .trophy-portrait::after { content: ""; position: absolute; inset: 0; z-index: 1; background: repeating-linear-gradient(135deg, rgba(8,12,11,.25) 0 5px, rgba(23,28,26,.16) 5px 10px); pointer-events: none; } +.boss-trophy.is-locked .trophy-portrait > span { color: #75817d; opacity: .42; } +.trophy-plaque { position: absolute; right: 10px; bottom: 12px; left: 10px; display: grid; } +.trophy-plaque small { color: color-mix(in srgb, var(--boss-accent) 78%, #d8d0b4); font-size: 7px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; } +.boss-trophy.is-locked .trophy-plaque small { color: #69756f; } +.trophy-plaque strong { margin-top: 2px; overflow: hidden; font-family: "Cinzel", serif; font-size: 12px; font-weight: 500; line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; } +.trophy-plaque span { margin-top: 5px; color: #768981; font-size: 8px; text-transform: uppercase; } +.trophy-state { position: absolute; top: 13px; left: 13px; z-index: 3; padding: 3px 6px; border: 1px solid color-mix(in srgb, var(--boss-accent) 58%, #716d5d); color: #fff0bb; background: rgba(9,17,14,.82); font-size: 7px; letter-spacing: .12em; text-transform: uppercase; } +.boss-trophy.is-locked .trophy-state { border-color: #56615d; color: #8b9893; background: rgba(12,16,15,.86); } +.trophy-note { margin-top: 8px; } +.boss-stats-heading { padding-top: 10px; } +.boss-stats-layout { height: 331px; display: grid; grid-template-columns: minmax(235px, .78fr) minmax(0, 1.22fr); gap: 11px; } +.boss-stat-selector { min-width: 0; display: grid; align-content: start; gap: 5px; } +.boss-stat-selector button { width: 100%; min-height: 52px; display: grid; grid-template-columns: 30px minmax(0, 1fr) 32px; align-items: center; gap: 8px; padding: 6px 9px; text-align: left; } +.boss-stat-selector button.is-selected { border-color: var(--gold); box-shadow: inset 3px 0 var(--gold); background: linear-gradient(90deg, rgba(93,73,23,.27), rgba(8,18,15,.88)); } +.boss-stat-selector button > i { width: 27px; height: 27px; display: grid; place-items: center; border: 1px solid #496159; color: var(--gold); font-style: normal; } +.boss-stat-selector button > span { min-width: 0; display: grid; } +.boss-stat-selector strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.boss-stat-selector small { overflow: hidden; color: #6f837b; font-size: 7px; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.boss-stat-selector button > b { color: var(--gold-strong); font-family: "Cinzel", serif; font-size: 16px; text-align: right; } +.leaderboard-panel { min-width: 0; overflow: hidden; border: 1px solid #435950; background: linear-gradient(180deg, rgba(11,25,21,.94), rgba(5,13,11,.94)); } +.leaderboard-panel > header { height: 53px; display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--line); background: rgba(59,47,17,.16); } +.leaderboard-panel > header span { min-width: 0; display: grid; } +.leaderboard-panel > header small { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; } +.leaderboard-panel > header strong { overflow: hidden; font: 500 13px "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; } +.leaderboard-panel > header > b { color: #88a79b; font-size: 8px; text-transform: uppercase; } +.leaderboard-rows { min-height: 218px; padding: 4px 8px; } +.leaderboard-rows > div, .leaderboard-self { min-height: 40px; display: grid; grid-template-columns: 34px minmax(0, 1fr) 44px; align-items: center; gap: 8px; padding: 4px 7px; border-bottom: 1px solid rgba(164,195,184,.1); } +.leaderboard-rows > div.is-you { background: rgba(102,81,24,.2); } +.leaderboard-rows > div > b, .leaderboard-self > b { color: var(--gold); font: 600 12px "Cinzel", serif; } +.leaderboard-rows span, .leaderboard-self span { min-width: 0; display: grid; } +.leaderboard-rows strong, .leaderboard-self strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.leaderboard-rows small, .leaderboard-self small { color: #6d8179; font-size: 6px; text-transform: uppercase; } +.leaderboard-rows em, .leaderboard-self em { color: #e8f3ee; font: 500 15px "Cinzel", serif; font-style: normal; text-align: right; } +.leaderboard-self { min-height: 48px; margin: 0 8px 7px; border: 1px solid rgba(232,200,114,.33); background: rgba(79,62,19,.2); } +.leaderboard-status, .leaderboard-empty { min-height: 218px; display: grid; place-items: center; padding: 20px; color: #71867e; font-size: 9px; text-align: center; text-transform: uppercase; } +.leaderboard-empty { min-height: 200px !important; border: 0 !important; } .profile-context { padding: 0 5.5% 18px; } .profile-context .context-header { margin: 0 -5.8%; } -.profile-stats { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid var(--line); } -.profile-stats span { display: grid; padding: 12px 9px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); } -.profile-stats span:nth-child(even) { border-right: 0; } +.profile-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border-bottom: 1px solid var(--line); } +.profile-stats span { min-width: 0; display: grid; padding: 8px 6px; border-right: 1px solid var(--line); } +.profile-stats span:last-child { border-right: 0; } .profile-stats small { color: #6d8179; font-size: clamp(7px, 1.5cqw, 9px); text-transform: uppercase; } -.profile-stats strong { color: var(--gold-strong); font-family: "Cinzel", serif; font-size: clamp(16px, 3.3cqw, 20px); font-weight: 500; } -.boss-log { margin-top: 13px; } +.profile-stats strong { color: var(--gold-strong); font-family: "Cinzel", serif; font-size: clamp(14px, 2.8cqw, 17px); font-weight: 500; } +.boss-log { margin-top: 7px; } .boss-log > span { color: #6c8179; font-size: 8px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; } -.boss-log button { width: 100%; min-height: 52px; display: grid; grid-template-columns: 34px 1fr 30px; align-items: center; gap: 10px; margin-top: 7px; padding: 7px 10px; text-align: left; } +.boss-log button { width: 100%; min-height: 36px; display: grid; grid-template-columns: 28px 1fr 30px; align-items: center; gap: 7px; margin-top: 3px; padding: 3px 8px; text-align: left; } .boss-log button.is-selected { border-left: 2px solid var(--gold); background: linear-gradient(90deg, rgba(91,75,31,0.22), rgba(8,21,17,0.85)); } -.boss-log button > i { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid #4d635b; color: var(--gold); font-style: normal; } +.boss-log button > i { width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid #4d635b; color: var(--gold); font-style: normal; } .boss-log button > span { display: grid; } .boss-log strong { font-size: clamp(10px, 2.15cqw, 13px); } .boss-log small { color: #687d75; font-size: clamp(7px, 1.45cqw, 9px); text-transform: uppercase; } @@ -2005,6 +2066,9 @@ button:focus-visible { .front-screen-header { height: 44px; grid-template-columns: 125px 1fr auto; gap: 7px; } .front-screen-header h1 { font-size: 11px; } .save-surface, .home-surface, .profile-surface, .settings-surface, .mode-surface { padding-right: 12px; padding-left: 12px; } + .profile-header { grid-template-columns: 125px minmax(0, 1fr) auto auto; } + .profile-view-tabs { gap: 2px; } + .profile-view-tabs button { min-height: 22px; padding: 3px 5px; font-size: 5px; } .save-slot-grid { height: calc(100% - 75px); gap: 5px; } .save-slot { height: 84%; padding: 7px 5px; } .slot-portrait { width: 36px; height: 36px; margin-top: 7px; font-size: 14px; } @@ -2054,6 +2118,40 @@ button:focus-visible { .collection-drop > p { right: 5px; bottom: 4px; left: 5px; padding-top: 3px; } .collection-note { margin-top: 5px; padding: 3px 5px; } .collection-note strong, .collection-note small { font-size: 5px; } + .trophy-heading { padding-top: 4px; } + .trophy-case { gap: 5px; } + .trophy-count-1 .boss-trophy { width: min(180px, 100%); } + .boss-trophy { height: 211px; } + .boss-trophy::before { inset: 3px; } + .trophy-portrait { height: 140px; } + .trophy-portrait > span { right: 6px; bottom: 3px; font-size: 10px; } + .trophy-plaque { right: 5px; bottom: 6px; left: 5px; } + .trophy-plaque small, .trophy-state { font-size: 4px; } + .trophy-plaque strong { font-size: 7px; } + .trophy-plaque span { margin-top: 2px; font-size: 4px; } + .trophy-state { top: 6px; left: 6px; padding: 2px 3px; } + .trophy-note { margin-top: 4px; } + .boss-stats-heading { padding-top: 3px; } + .boss-stats-layout { height: 213px; grid-template-columns: minmax(138px, .82fr) minmax(0, 1.18fr); gap: 4px; } + .boss-stat-selector { gap: 2px; } + .boss-stat-selector button { min-height: 34px; grid-template-columns: 18px minmax(0, 1fr) 18px; gap: 3px; padding: 2px 4px; } + .boss-stat-selector button > i { width: 16px; height: 16px; font-size: 7px; } + .boss-stat-selector strong { font-size: 6px; } + .boss-stat-selector small { font-size: 4px; } + .boss-stat-selector button > b { font-size: 9px; } + .leaderboard-panel > header { height: 35px; padding: 3px 6px; } + .leaderboard-panel > header small { font-size: 4px; } + .leaderboard-panel > header strong { font-size: 7px; } + .leaderboard-panel > header > b { font-size: 4px; } + .leaderboard-rows { min-height: 139px; padding: 2px 4px; } + .leaderboard-rows > div, .leaderboard-self { min-height: 25px; grid-template-columns: 20px minmax(0, 1fr) 24px; gap: 3px; padding: 2px 3px; } + .leaderboard-rows > div > b, .leaderboard-self > b { font-size: 7px; } + .leaderboard-rows strong, .leaderboard-self strong { font-size: 5px; } + .leaderboard-rows small, .leaderboard-self small { font-size: 3px; } + .leaderboard-rows em, .leaderboard-self em { font-size: 8px; } + .leaderboard-self { min-height: 29px; margin: 0 4px 3px; } + .leaderboard-status, .leaderboard-empty { min-height: 139px; padding: 7px; font-size: 5px; } + .leaderboard-empty { min-height: 130px !important; } .settings-layout { gap: 7px; padding-top: 7px; } .volume-setting { height: 98px; padding: 7px; } .volume-setting strong { font-size: 8px; } @@ -2141,6 +2239,10 @@ button:focus-visible { .gear-slot-list button > i { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid #3d554c; color: #8fc4b1; font-style: normal; } .gear-owner-list button.is-selected, .gear-slot-list button.is-selected { border-color: var(--gold); box-shadow: inset 3px 0 var(--gold); background: rgba(72,58,21,.16); } +.gear-owner-list button.is-upgrade-ready, +.gear-slot-list button.is-upgrade-ready { border-color: #65e6a8; box-shadow: inset 3px 0 #65e6a8, 0 0 12px rgba(101,230,168,.28); background: rgba(29,103,72,.2); } +.gear-owner-list button.is-upgrade-ready.is-selected, +.gear-slot-list button.is-upgrade-ready.is-selected { box-shadow: inset 3px 0 var(--gold), 0 0 0 1px rgba(101,230,168,.35), 0 0 12px rgba(101,230,168,.28); } .gear-preview { padding: 16px; border: 1px solid var(--line); background: radial-gradient(circle at 70% 20%, rgba(232,200,114,.1), transparent 45%), rgba(7,18,15,.84); } .gear-preview > span { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; } .gear-preview h2 { margin: 8px 0; font: 500 17px "Cinzel", serif; } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vite.config.ts b/vite.config.ts index 387e4ee..cdf9913 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ port: 4173, strictPort: true, allowedHosts: ["iwanttoheal.phenomrom.com"], + proxy: { + "/api": "http://127.0.0.1:4174", + }, }, preview: { host: "0.0.0.0", @@ -18,5 +21,6 @@ export default defineConfig({ }, test: { environment: "node", + include: ["src/**/*.test.ts"], }, });