Compare commits

..
2 Commits
Author SHA1 Message Date
Warren H 77fd434226 Release v0.1.7 2026-07-12 2026-07-12 23:39:57 -04:00
Warren H 122f159b94 Release v0.1.6 2026-07-12 2026-07-12 23:20:15 -04:00
62 changed files with 2744 additions and 670 deletions
+2
View File
@@ -1,5 +1,7 @@
node_modules/ node_modules/
dist/ dist/
data/
backups/
game_assets/ game_assets/
*.tsbuildinfo *.tsbuildinfo
vite.config.js vite.config.js
+30 -4
View File
@@ -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 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. 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 ## Paths
```text ```text
@@ -13,6 +24,9 @@ Local Gitea bare repository:
Runnable working checkout: Runnable working checkout:
/mnt/usbssds/apps/iwanttoheal-mmo/app /mnt/usbssds/apps/iwanttoheal-mmo/app
Persistent game data:
/mnt/usbssds/apps/iwanttoheal-mmo/data
Public URL: Public URL:
https://iwanttoheal.phenomrom.com 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: Clone entirely through the local filesystem:
```sh ```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 \ sudo git config --global --add safe.directory \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
sudo git clone \ sudo git clone \
@@ -72,8 +86,10 @@ services:
iwanttoheal: iwanttoheal:
image: node:24-bookworm-slim image: node:24-bookworm-slim
command: >- 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: environment:
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost"
DATA_DIR: /app/data
HOST: 0.0.0.0 HOST: 0.0.0.0
PORT: "4173" PORT: "4173"
init: true init: true
@@ -82,11 +98,21 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app - /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
- /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data
working_dir: /app working_dir: /app
``` ```
This game has no server database. Do not add `db:init`, `/app/data`, cookie, Do not remove the `/app/data` mount or place the SQLite database inside the source
CORS, or proxy environment settings from the older game. 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 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 host port `4173`, so the old and MMO apps cannot run simultaneously while both
+38 -16
View File
@@ -2,16 +2,23 @@
Playable low-poly third-person combat vertical slice for AYN Thor's dual displays. 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 ## Run
```bash ```bash
pnpm install pnpm install
pnpm dev:api
# In a second terminal:
pnpm dev 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 ## Android / AYN Thor test APK
@@ -23,6 +30,10 @@ installable debug APK:
pnpm android: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 Output is written under `android/app/build/outputs/apk/debug/`. With Android
platform tools and a connected Thor, build and install it with: 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: Complete first-install, local-Gitea clone, YAML, update, and verification steps:
[DEPLOYMENT.md](DEPLOYMENT.md). [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` - Public URL: `https://iwanttoheal.phenomrom.com`
- Host/container port: `4173` - Host/container port: `4173`
- App directory: `/mnt/usbssds/apps/iwanttoheal-mmo/app` - 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 Clone from the TrueNAS-local Gitea bare repository into the app directory, then
deploy `compose.yaml`. The expected source path is: 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 /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`; - use `corepack pnpm install --frozen-lockfile`, not `npm ci`;
- remove `npm run db:init` because this game has no server database; - run `pnpm db:init` before starting the production server;
- remove `COOKIE_SECURE`, `CORS_ORIGINS`, and `TRUST_PROXY`; the static Vite - mount `/mnt/usbssds/apps/iwanttoheal-mmo/data` at `/app/data`;
preview server does not consume them; - keep `CORS_ORIGINS` configured for the public site and Capacitor host;
- remove `/app/data`; offline saves live in each player's browser/Android WebView - keep the source checkout and persistent data in separate mounts.
storage, not on TrueNAS.
The existing reverse proxy can keep forwarding the public hostname to port The existing reverse proxy forwards the public hostname to port `4173`, including
`4173`. Add server data and authentication environment variables only when an all `/api` routes.
actual sync API is introduced.
## Publish updates to Gitea ## Publish updates to Gitea
@@ -109,18 +131,18 @@ outside the repository.
- `WASD` / left stick: move - `WASD` / left stick: move
- `Q` and `E` / D-pad: cycle party target - `Q` and `E` / D-pad: cycle party target
- `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal - `1``6`: cast Smite, Renew, Shield, Purify, Radiance, Flash Heal
- Gamepad: `X`, `Y`, `B`, `A`, `LB`, `RB` map to those abilities - Gamepad: PlayStation ``, ``, ``, ``, `L1`, `R1` map to those abilities
- `M`: tactical map - `M`: tactical map
- `I`: inventory and item tooltip - `I`: inventory and item tooltip
- `Enter` / Start: begin or reset encounter - `Enter` / `START`: begin or reset encounter
Touch controls on lower display support party targeting, ability casting, map, and inventory. 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 - Five-member AI party with Disc Priest healer
- Animated Druid healer, Knight tank, Ranger, Rogue, and Mage party models - 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 - 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 - Three-charge cycle into a five-second stack marker and 300-damage shared pounce
- Tank pressure, party-wide Cinder Nova, dispellable Ember Brand - Tank pressure, party-wide Cinder Nova, dispellable Ember Brand
@@ -102,7 +102,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X); float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y); 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<String> currentTokens = new HashSet<>(); Set<String> currentTokens = new HashSet<>();
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_X), "Button14", "Button15"); 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); 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) { private void dispatchNativeControllerToken(String token, boolean repeat) {
if (bridge == null || bridge.getWebView() == null) return; if (bridge == null || bridge.getWebView() == null) return;
String script = 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; if (bridge == null || bridge.getWebView() == null) return;
String script = String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller-motion'," "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)); bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
} }
private void clearHeldControllerState() { private void clearHeldControllerState() {
activeMotionTokens.clear(); activeMotionTokens.clear();
lastMotionDispatchAt.clear(); lastMotionDispatchAt.clear();
dispatchNativeControllerMotion(0.0f, 0.0f); dispatchNativeControllerMotion(0.0f, 0.0f, 0.0f, 0.0f);
if (bridge == null || bridge.getWebView() == null) return; if (bridge == null || bridge.getWebView() == null) return;
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript( bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
"window.dispatchEvent(new Event('iwt-native-controller-reset'));", "window.dispatchEvent(new Event('iwt-native-controller-reset'));",
+4 -1
View File
@@ -2,8 +2,10 @@ services:
iwanttoheal: iwanttoheal:
image: node:24-bookworm-slim image: node:24-bookworm-slim
command: >- 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: environment:
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost"
DATA_DIR: /app/data
HOST: 0.0.0.0 HOST: 0.0.0.0
PORT: "4173" PORT: "4173"
init: true init: true
@@ -12,4 +14,5 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app - /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
- /mnt/usbssds/apps/iwanttoheal-mmo/data:/app/data
working_dir: /app working_dir: /app
+68
View File
@@ -0,0 +1,68 @@
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);
CREATE TABLE IF NOT EXISTS rogue_trials_endless_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_boss_kills INTEGER NOT NULL DEFAULT 0 CHECK (highest_boss_kills >= 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 rogue_trials_endless_rank_idx
ON rogue_trials_endless_records (highest_boss_kills DESC, updated_at ASC);
+8 -3
View File
@@ -1,17 +1,22 @@
{ {
"name": "i-want-to-heal", "name": "i-want-to-heal",
"private": true, "private": true,
"version": "0.1.5", "version": "0.1.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "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", "build": "tsc -b && vite build",
"android:sync": "pnpm run build && cap sync android", "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": "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", "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", "publish:gitea": "python3 scripts/publish_gitea.py",
"test": "vitest run", "test": "vitest run && node --test server/game-api.test.mjs",
"test:watch": "vitest", "test:watch": "vitest",
"assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write", "assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write",
"assets:import": "node scripts/import-game-asset.mjs" "assets:import": "node scripts/import-game-asset.mjs"
+17
View File
@@ -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();
}
+14
View File
@@ -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")}`);
+438
View File
@@ -0,0 +1,438 @@
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 320 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 10128 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);
const highestEndlessKills = normalizeNonNegativeInteger(save.stats?.highestRogueTrialsEndlessKills);
database.prepare(`
INSERT INTO rogue_trials_endless_records (account_id, slot_id, highest_boss_kills, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
highest_boss_kills = excluded.highest_boss_kills,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestEndlessKills);
}
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,
};
}
function rogueTrialsEndlessLeaderboard(database, accountId, slotId) {
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.highest_boss_kills DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.highest_boss_kills AS highestBossKills,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM rogue_trials_endless_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_boss_kills > 0
)
SELECT * FROM ranked ORDER BY highestBossKills DESC, updatedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "rogue-trials-endless",
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestBossKills")),
current: current ? leaderboardEntry(current, "highestBossKills") : 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));
}
if (path === "/api/leaderboards/rogue-trials-endless" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, rogueTrialsEndlessLeaderboard(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() };
}
+115
View File
@@ -0,0 +1,115 @@
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, highestRogueTrialsEndlessKills) {
return {
schemaVersion: 5,
slotId,
hunterName,
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills },
};
}
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 highestEndlessKills = 24 - index * 3;
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, highestEndlessKills) }),
});
assert.equal(upload.response.status, 200);
players.push({ token, kills, highestRound, highestEndlessKills });
}
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 endlessBoard = await json("/api/leaderboards/rogue-trials-endless?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(endlessBoard.body.kind, "rogue-trials-endless");
assert.equal(endlessBoard.body.top.length, 5);
assert.equal(endlessBoard.body.top[0].value, 24);
assert.equal(endlessBoard.body.current.rank, 6);
assert.equal(endlessBoard.body.current.value, current.highestEndlessKills);
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);
});
+62
View File
@@ -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);
+34 -15
View File
@@ -8,7 +8,7 @@ import type { BossId } from "./game/types";
import type { DifficultySlug } from "./game/progression/loot"; import type { DifficultySlug } from "./game/progression/loot";
import { useActionBindings } from "./game/useGameLoop"; import { useActionBindings } from "./game/useGameLoop";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen"; import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync"; import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen }))); const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
@@ -32,20 +32,25 @@ export default function App() {
const touchActiveSave = useFrontendStore((state) => state.touchActiveSave); const touchActiveSave = useFrontendStore((state) => state.touchActiveSave);
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory); const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory); const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat);
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards); const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
const rewardedBossInstances = useRef(new Set<string>()); const rewardedBossInstances = useRef(new Set<string>());
const screenRef = useRef(screen); const screenRef = useRef(screen);
screenRef.current = screen; screenRef.current = screen;
const leaveGame = useCallback(() => { const leaveGame = useCallback(() => {
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
updateActiveHealerInventory(useGameStore.getState().inventory); updateActiveHealerInventory(useGameStore.getState().inventory);
touchActiveSave(); touchActiveSave();
navigate("home"); navigate("home");
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
}, [navigate, touchActiveSave, updateActiveHealerInventory]); }, [navigate, touchActiveSave, updateActiveHealerInventory]);
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => { const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => {
if (!hunter) return; if (!hunter) return;
const progress = hunter.healers[hunter.activeClassId]; const progress = hunter.healers[hunter.activeClassId];
const runMode = useFrontendStore.getState().selectedMode === "roguelike-pve" ? "roguelike" : "encounter"; const selectedMode = useFrontendStore.getState().selectedMode;
const launchDifficulty = runMode === "roguelike" const runMode = selectedMode === "roguelike-pve" ? "roguelike" : selectedMode === "rogue-trials" ? "rogue-trials" : "encounter";
const launchDifficulty = runMode !== "encounter"
? "initiate" ? "initiate"
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug; : requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
rewardedBossInstances.current.clear(); rewardedBossInstances.current.clear();
@@ -65,6 +70,11 @@ export default function App() {
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch); return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
}, [launchGame]); }, [launchGame]);
useEffect(() => {
window.addEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
}, [leaveGame]);
useActionBindings(screen === "game", leaveGame); useActionBindings(screen === "game", leaveGame);
useEffect(() => { useEffect(() => {
@@ -75,19 +85,28 @@ export default function App() {
useEffect(() => { useEffect(() => {
return useGameStore.subscribe((state, previousState) => { return useGameStore.subscribe((state, previousState) => {
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing" const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|| previousState.phase === "intermission" && state.phase === "combat"; || previousState.phase === "intermission" && state.phase === "combat"
if (state.phase === "briefing" || previousState.phase === "intermission" && state.phase === "combat") { || previousState.phase === "victory" && state.phase === "combat" && state.endlessMode;
if (state.phase === "briefing"
|| previousState.phase === "intermission" && state.phase === "combat"
|| previousState.phase === "victory" && state.phase === "combat" && state.endlessMode) {
rewardedBossInstances.current.clear(); rewardedBossInstances.current.clear();
} }
if (startedFreshEncounter) clearRecentRewards(); if (startedFreshEncounter) clearRecentRewards();
if (screenRef.current !== "game") return; if (screenRef.current !== "game") return;
if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRoguelikeDefeat(state.round);
}
if (state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRogueTrialsEndlessDefeat(state.endlessBossKills);
}
const bossCount = 1 + state.additionalBosses.length; const bossCount = 1 + state.additionalBosses.length;
if (state.boss.hp <= 0 && previousState.boss.hp > 0) { if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
const primaryInstanceId = `boss-0-${state.boss.id}`; const primaryInstanceId = state.bossInstanceId;
if (!rewardedBossInstances.current.has(primaryInstanceId)) { if (!rewardedBossInstances.current.has(primaryInstanceId)) {
rewardedBossInstances.current.add(primaryInstanceId); if (!state.endlessMode) rewardedBossInstances.current.add(primaryInstanceId);
const defeatedBefore = (state.round - 1) * bossCount; 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); recordBossVictory(state.boss.id, rewardDifficulty);
} }
} }
@@ -95,23 +114,23 @@ export default function App() {
const entry = state.additionalBosses[index]; const entry = state.additionalBosses[index];
const previous = previousState.additionalBosses[index]; const previous = previousState.additionalBosses[index];
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0); const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue; if (!justDefeated || !state.endlessMode && rewardedBossInstances.current.has(entry.instanceId)) continue;
rewardedBossInstances.current.add(entry.instanceId); if (!state.endlessMode) rewardedBossInstances.current.add(entry.instanceId);
const defeatedBefore = (state.round - 1) * bossCount + index + 1; 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); recordBossVictory(entry.boss.id, rewardDifficulty);
} }
}); });
}, [clearRecentRewards, recordBossVictory]); }, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
return ( return (
<main className="prototype-shell"> <main className="app-shell">
<header className="prototype-header"> <header className="app-header">
<div><span>THOR / DUAL DISPLAY</span><strong>I Want To Heal</strong></div> <div><span>THOR / DUAL DISPLAY</span><strong>I Want To Heal</strong></div>
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p> <p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
</header> </header>
{screen === "game" {screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen />} /></Suspense> ? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
: <FrontEnd onLaunch={launchGame} />} : <FrontEnd onLaunch={launchGame} />}
</main> </main>
); );
+57
View File
@@ -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 (
<primitive
object={model.clone}
position={[-model.center.x * model.scale, -model.center.y * model.scale, -model.center.z * model.scale]}
rotation={[0, bossId === "bulldrome" ? 0.35 : ALTERNATE_BOSS_CONFIG[bossId].rotationOffset + 0.35, 0]}
scale={model.scale}
/>
);
}
export function BossTrophyPortrait({ bossId }: { bossId: BossId }) {
const boss = BOSS_DEFINITIONS[bossId];
return (
<div className="trophy-portrait" aria-label={`${boss.name} portrait`} role="img">
<Canvas
camera={{ position: [3.4, 2.35, 4.8], zoom: 70, near: 0.1, far: 30 }}
dpr={1}
frameloop="demand"
gl={{ alpha: true, antialias: true, powerPreference: "low-power" }}
orthographic
>
<ambientLight intensity={1.9} />
<directionalLight color="#fff3cf" intensity={3.2} position={[3, 5, 4]} />
<directionalLight color={boss.accent} intensity={2.1} position={[-4, 2, -2]} />
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
</Canvas>
<span aria-hidden="true">{boss.icon}</span>
</div>
);
}
+37 -14
View File
@@ -6,6 +6,7 @@ import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../g
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat"; import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types"; import type { BottomTab, PartyMember } from "../game/types";
import { useFrontendStore } from "../frontend/store"; import { useFrontendStore } from "../frontend/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
function RewardSummary() { function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards); const rewards = useFrontendStore((state) => state.recentRewards);
@@ -170,7 +171,7 @@ function BriefingPanel() {
<span>Chosen discipline</span> <span>Chosen discipline</span>
<h2>{healer.specialization}</h2> <h2>{healer.specialization}</h2>
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p> <p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>START / ENTER</small></button> <button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
</div> </div>
<div className="briefing-kit"> <div className="briefing-kit">
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div> <div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
@@ -189,29 +190,51 @@ function BriefingPanel() {
); );
} }
function EndPanel() { function EndPanel({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const endlessChoiceSelection = useGameStore((state) => state.endlessChoiceSelection);
const setEndlessChoiceSelection = useGameStore((state) => state.setEndlessChoiceSelection);
const startRogueTrialsEndless = useGameStore((state) => state.startRogueTrialsEndless);
const time = useGameStore((state) => state.time); const time = useGameStore((state) => state.time);
const party = useGameStore((state) => state.party); const party = useGameStore((state) => state.party);
const restart = useGameStore((state) => state.restart); const restart = useGameStore((state) => state.restart);
const startEncounter = useGameStore((state) => state.startEncounter); const startEncounter = useGameStore((state) => state.startEncounter);
const totalHp = party.reduce((sum, member) => sum + member.hp, 0); const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0); const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
return ( return (
<div className={`end-panel end-${phase}`}> <div className={`end-panel end-${phase}`}>
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span> <span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
<small>{phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small> <small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2> <h2>{showEndlessChoice ? "The trial can continue" : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<div className="result-stats"> <div className="result-stats">
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span> <span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span> <span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
<span><small>Boss</small><strong>{phase === "victory" ? "Defeated" : "Standing"}</strong></span> <span><small>{endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
</div> </div>
{phase === "victory" && <RewardSummary />} {phase === "victory" && <RewardSummary />}
<div className="end-actions"> {showEndlessChoice ? <div className="end-actions endless-choice-actions">
<button
className={endlessChoiceSelection === "continue" ? "is-controller-focused" : ""}
onFocus={() => setEndlessChoiceSelection("continue")}
onPointerEnter={() => setEndlessChoiceSelection("continue")}
onClick={startRogueTrialsEndless}
>Continue Endless</button>
<button
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
onFocus={() => setEndlessChoiceSelection("quit")}
onPointerEnter={() => setEndlessChoiceSelection("quit")}
onClick={onExit}
>Quit to Main Menu</button>
</div> : <div className="end-actions">
<button onClick={() => { restart(); startEncounter(); }}>Run again</button> <button onClick={() => { restart(); startEncounter(); }}>Run again</button>
<button className="secondary" onClick={restart}>Return to briefing</button> <button className="secondary" onClick={endlessDefeat ? onExit : restart}>{endlessDefeat ? "Return to main menu" : "Return to briefing"}</button>
</div> </div>}
</div> </div>
); );
} }
@@ -225,16 +248,16 @@ function IntermissionStatusPanel() {
<h2>Choose on top display</h2> <h2>Choose on top display</h2>
<p>Next encounter stays locked until one blessing is claimed.</p> <p>Next encounter stays locked until one blessing is claimed.</p>
<RewardSummary /> <RewardSummary />
<small>Use D-pad to choose · A to claim</small> <small>Use D-pad to choose · {DEFAULT_CONTROLLER_GLYPHS.confirm} to claim</small>
</div> </div>
); );
} }
function CombatPanel() { function CombatPanel({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
if (phase === "briefing") return <BriefingPanel />; if (phase === "briefing") return <BriefingPanel />;
if (phase === "intermission") return <IntermissionStatusPanel />; if (phase === "intermission") return <IntermissionStatusPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel />; if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} />;
return <div className="combat-panel"><PartyList /><AbilityTray /></div>; return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
} }
@@ -340,7 +363,7 @@ const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
{ id: "pack", label: "Pack", icon: "▧", key: "I" }, { id: "pack", label: "Pack", icon: "▧", key: "I" },
]; ];
export function BottomScreen() { export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
const activeTab = useGameStore((state) => state.activeTab); const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab); const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
@@ -358,13 +381,13 @@ export function BottomScreen() {
</nav> </nav>
</header> </header>
<main className="lower-content"> <main className="lower-content">
{activeTab === "combat" && <CombatPanel />} {activeTab === "combat" && <CombatPanel onExit={onExit} />}
{activeTab === "map" && <MapPanel />} {activeTab === "map" && <MapPanel />}
{activeTab === "pack" && <PackPanel />} {activeTab === "pack" && <PackPanel />}
</main> </main>
{paused && ( {paused && (
<div className="lower-pause-overlay" aria-hidden="true"> <div className="lower-pause-overlay" aria-hidden="true">
<span>PAUSED</span><strong>Encounter suspended</strong><small>START / ESC resumes · selects menu action</small> <span>PAUSED</span><strong>Encounter suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · selects menu action</small>
</div> </div>
)} )}
</section> </section>
+22 -6
View File
@@ -1,25 +1,40 @@
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 { HEALER_CLASSES } from "../game/healers";
import { useGameStore } from "../game/store"; import { isRunBuffInputLocked, useGameStore } from "../game/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function BuffDraftPanel({ className = "" }: { className?: string }) { export function BuffDraftPanel({ className = "" }: { className?: string }) {
const round = useGameStore((state) => state.round); const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode);
const healerClassId = useGameStore((state) => state.healerClassId); const healerClassId = useGameStore((state) => state.healerClassId);
const runBuffRanks = useGameStore((state) => state.runBuffRanks); const runBuffRanks = useGameStore((state) => state.runBuffRanks);
const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId); const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId);
const choices = useGameStore((state) => state.draftBuffIds); const choices = useGameStore((state) => state.draftBuffIds);
const selected = useGameStore((state) => state.selectedRunBuffId); const selected = useGameStore((state) => state.selectedRunBuffId);
const inputUnlockAt = useGameStore((state) => state.runBuffInputUnlockAt);
const setSelected = useGameStore((state) => state.setSelectedRunBuff); const setSelected = useGameStore((state) => state.setSelectedRunBuff);
const choose = useGameStore((state) => state.chooseRunBuff); const choose = useGameStore((state) => state.chooseRunBuff);
const continueRun = useGameStore((state) => state.continueRoguelikeRound); const continueRun = useGameStore((state) => state.continueRoguelikeRound);
const nextRound = round + 1; const nextRound = round + 1;
const nextBossCount = runMode === "rogue-trials" && nextRound === ROGUE_TRIALS_TRIO_ROUND ? 3 : 2;
const abilities = HEALER_CLASSES[healerClassId].abilities; 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 ( return (
<div className={`buff-draft ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`}> <div className={`buff-draft ${inputLocked ? "is-input-locked" : ""} ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`} aria-busy={inputLocked}>
<header> <header>
<span>Round {round} cleared</span> <span>Round {round} cleared</span>
<h2>Choose one blessing</h2> <h2>Choose one blessing</h2>
<p>Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p> <p>Claim required. Round {nextRound} begins with {nextBossCount === 3 ? "an unseen trio" : "two new bosses"} at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
</header> </header>
<div className={`buff-choice-grid choice-count-${choices.length}`}> <div className={`buff-choice-grid choice-count-${choices.length}`}>
{choices.length > 0 ? choices.map((buffId) => { {choices.length > 0 ? choices.map((buffId) => {
@@ -35,6 +50,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
onFocus={() => setSelected(buffId)} onFocus={() => setSelected(buffId)}
onPointerEnter={() => setSelected(buffId)} onPointerEnter={() => setSelected(buffId)}
onClick={() => choose(buffId)} onClick={() => choose(buffId)}
disabled={inputLocked}
aria-pressed={selected === buffId} aria-pressed={selected === buffId}
> >
<i>{buff.icon}</i> <i>{buff.icon}</i>
@@ -44,7 +60,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
</button> </button>
); );
}) : ( }) : (
<button className="buff-mastery-continue is-controller-focused" onClick={continueRun}> <button className="buff-mastery-continue is-controller-focused" onClick={continueRun} disabled={inputLocked}>
<i></i> <i></i>
<span><small>Full mastery</small><strong>Continue Without Buff</strong></span> <span><small>Full mastery</small><strong>Continue Without Buff</strong></span>
<b>All 18 blessings reached maximum rank.</b> <b>All 18 blessings reached maximum rank.</b>
@@ -52,7 +68,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
</button> </button>
)} )}
</div> </div>
<footer>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>A / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</footer> <footer>{inputLocked ? <b>Choices ready in a moment</b> : <>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer>
</div> </div>
); );
} }
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting"; import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { subscribeControllerToken } from "../input/controller"; import { subscribeControllerToken } from "../input/controller";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) { export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
const dedicatedSurface = new URLSearchParams(window.location.search).get("display"); const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
@@ -45,7 +46,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")} onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"} aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
> >
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>SELECT / TAB</small> <b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
</button> </button>
</div> </div>
); );
+180 -37
View File
@@ -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 { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository"; import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
import { useActiveHunter, useFrontendStore } from "../frontend/store"; import { useActiveHunter, useFrontendStore } from "../frontend/store";
@@ -19,8 +19,10 @@ import {
GEAR_STAT_LABELS, GEAR_STAT_LABELS,
MAX_GEAR_LEVEL, MAX_GEAR_LEVEL,
canAffordGearUpgrade, canAffordGearUpgrade,
canUpgradeGearSlot,
gearBonusText, gearBonusText,
gearUpgradeCosts, gearUpgradeCosts,
type GearOwnerId,
} from "../game/progression/gear"; } from "../game/progression/gear";
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot"; import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot";
import { import {
@@ -33,7 +35,11 @@ import {
passiveInfusionUnlocked, passiveInfusionUnlocked,
} from "../game/progression/infusions"; } from "../game/progression/infusions";
import { requestDisplaySurface } from "../platform/displayRouting"; import { requestDisplaySurface } from "../platform/displayRouting";
import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository";
import { DualDisplayFrame } from "./DualDisplayFrame"; import { DualDisplayFrame } from "./DualDisplayFrame";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait })));
function FocusButton({ function FocusButton({
id, id,
@@ -71,10 +77,11 @@ function BrandMark({ compact = false }: { compact?: boolean }) {
} }
function ControllerLegend({ back = false }: { back?: boolean }) { function ControllerLegend({ back = false }: { back?: boolean }) {
return <div className="controller-legend"><span><b>A</b> Select</span>{back && <span><b>B</b> Back</span>}<span><b></b> Navigate</span></div>; return <div className="controller-legend"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>{back && <span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>}<span><b></b> Navigate</span></div>;
} }
function LoginScreen() { function LoginScreen() {
const restoreSession = useFrontendStore((state) => state.restoreSession);
const signIn = useFrontendStore((state) => state.signIn); const signIn = useFrontendStore((state) => state.signIn);
const createAccount = useFrontendStore((state) => state.createAccount); const createAccount = useFrontendStore((state) => state.createAccount);
const continueOffline = useFrontendStore((state) => state.continueOffline); const continueOffline = useFrontendStore((state) => state.continueOffline);
@@ -83,6 +90,12 @@ function LoginScreen() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const usernameRef = useRef<HTMLInputElement>(null); const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null); const passwordRef = useRef<HTMLInputElement>(null);
const restoreStarted = useRef(false);
useEffect(() => {
if (restoreStarted.current) return;
restoreStarted.current = true;
void restoreSession();
}, [restoreSession]);
const actions = useMemo<MenuAction[]>(() => [ const actions = useMemo<MenuAction[]>(() => [
{ id: "username", run: () => usernameRef.current?.focus() }, { id: "username", run: () => usernameRef.current?.focus() },
{ id: "password", run: () => passwordRef.current?.focus() }, { id: "password", run: () => passwordRef.current?.focus() },
@@ -115,6 +128,9 @@ function LoginScreen() {
onChange={(event) => setUsername(event.target.value)} onChange={(event) => setUsername(event.target.value)}
onFocus={() => controller.focus("username")} onFocus={() => controller.focus("username")}
autoComplete="username" autoComplete="username"
maxLength={20}
minLength={3}
pattern="[A-Za-z0-9_]+"
required required
/> />
<label htmlFor="account-password">Password</label> <label htmlFor="account-password">Password</label>
@@ -127,6 +143,8 @@ function LoginScreen() {
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
onFocus={() => controller.focus("password")} onFocus={() => controller.focus("password")}
autoComplete="current-password" autoComplete="current-password"
maxLength={128}
minLength={10}
required required
/> />
<FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit"> <FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit">
@@ -150,8 +168,8 @@ function LoginScreen() {
<span className="context-kicker">How saving works</span> <span className="context-kicker">How saving works</span>
<ol> <ol>
<li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li> <li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li>
<li><b>02</b><span><strong>Create or sign in</strong><small>Username and password unlock online sync.</small></span></li> <li><b>02</b><span><strong>Create or sign in</strong><small>Account is secured by the TrueNAS game server.</small></span></li>
<li><b>03</b><span><strong>Move devices</strong><small>Sign in, then upload or download an online copy.</small></span></li> <li><b>03</b><span><strong>Move devices</strong><small>Upload or download any of your three server save slots.</small></span></li>
</ol> </ol>
</div> </div>
<div className="device-route"><span>PC</span><i></i><b>ONLINE COPY</b><i></i><span>THOR</span></div> <div className="device-route"><span>PC</span><i></i><b>ONLINE COPY</b><i></i><span>THOR</span></div>
@@ -318,7 +336,7 @@ function SaveScreen() {
{selected.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>} {selected.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>}
<div className="save-actions"> <div className="save-actions">
<FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}> <FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}>
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>A</small> {hasLocal ? "Continue offline save" : "Create new hunter"}<small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small>
</FocusButton> </FocusButton>
<div className="sync-actions"> <div className="sync-actions">
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}> Sync offline to server</FocusButton> <FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}> Sync offline to server</FocusButton>
@@ -339,6 +357,7 @@ function SaveScreen() {
const HOME_MODES: { id: GameModeId; icon: string; label: string; copy: string }[] = [ const HOME_MODES: { id: GameModeId; icon: string; label: string; copy: string }[] = [
{ id: "roguelike-pve", icon: "✦", label: "PVE", copy: "Randomized roguelike runs" }, { 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: "dungeons", icon: "♜", label: "Dungeons", copy: "Choose your boss encounter" },
{ id: "roguelike-pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" }, { id: "roguelike-pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" },
{ id: "stadium-pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" }, { id: "stadium-pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" },
@@ -351,10 +370,11 @@ function HomeScreen() {
const selectHealerClass = useFrontendStore((state) => state.selectHealerClass); const selectHealerClass = useFrontendStore((state) => state.selectHealerClass);
const navigate = useFrontendStore((state) => state.navigate); const navigate = useFrontendStore((state) => state.navigate);
const actions = useMemo<MenuAction[]>(() => [ const actions = useMemo<MenuAction[]>(() => [
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "dungeons", down: "roguelike-pvp" } }, { id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "rogue-trials", down: "roguelike-pvp" } },
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "roguelike-pve", down: "stadium-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: "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: "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: "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" } }, { id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "gear", down: "class-shaman" } },
@@ -380,7 +400,6 @@ function HomeScreen() {
top={ top={
<FrontSurface className="home-surface" ariaLabel="Main menu"> <FrontSurface className="home-surface" ariaLabel="Main menu">
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header> <header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
<div className="home-title"><span>Choose your hunt</span><h1>Where are you needed?</h1></div>
<div className="mode-grid"> <div className="mode-grid">
{HOME_MODES.map((mode) => ( {HOME_MODES.map((mode) => (
<FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}> <FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}>
@@ -422,39 +441,137 @@ function HomeScreen() {
); );
} }
type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless";
function ProfileScreen() { function ProfileScreen() {
const hunter = useActiveHunter(); const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId);
const navigate = useFrontendStore((state) => state.navigate); const navigate = useFrontendStore((state) => state.navigate);
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]); const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? ""); 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 collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
const [selectedStat, setSelectedStat] = useState<ProfileStatId>("roguelike");
const [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null);
const [leaderboardStatus, setLeaderboardStatus] = useState("");
useEffect(() => {
if (selectedStat === "roguelike" || selectedStat === "rogue-trials-endless" || 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)
: selectedStat === "rogue-trials-endless"
? onlineRepository.rogueTrialsEndlessLeaderboard(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<MenuAction[]>(() => [ const actions = useMemo<MenuAction[]>(() => [
{ 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-rogue-trials-endless" } },
{ id: "stat-rogue-trials-endless", run: () => setSelectedStat("rogue-trials-endless"), neighbors: { up: "stat-roguelike", down: `stat-${collection.bosses[0].bossId}` } },
...collection.bosses.map((boss, index) => ({
id: `stat-${boss.bossId}`,
run: () => setSelectedStat(boss.bossId),
neighbors: {
up: index === 0 ? "stat-rogue-trials-endless" : `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) })), ...collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })),
{ id: "back", run: () => navigate("home") }, { id: "back", run: () => navigate("home") },
], [collections, navigate]); ], [collection, collectionView, collections, navigate]);
const controller = useMenuController(actions, { onBack: () => navigate("home") }); const controller = useMenuController(actions, { onBack: () => navigate("home") });
if (!hunter || !collection) return null; if (!hunter || !collection) return null;
const activeHealer = HEALER_CLASSES[hunter.activeClassId]; const activeHealer = HEALER_CLASSES[hunter.activeClassId];
const activeProgress = hunter.healers[hunter.activeClassId]; const activeProgress = hunter.healers[hunter.activeClassId];
const earned = collection.drops.filter((drop) => drop.count > 0).length; const earned = collection.drops.filter((drop) => drop.count > 0).length;
const trophiesEarned = collection.bosses.filter((boss) => boss.pet.count > 0).length;
const selectedStatValue = selectedStat === "roguelike"
? hunter.stats.highestRoguelikeRound
: selectedStat === "rogue-trials-endless"
? hunter.stats.highestRogueTrialsEndlessKills
: hunter.stats.bossKills[selectedStat] ?? 0;
const selectedStatLabel = selectedStat === "roguelike"
? "Roguelike rounds"
: selectedStat === "rogue-trials-endless"
? "Rogue Trials endless kills"
: BOSS_DEFINITIONS[selectedStat].name;
return ( return (
<DualDisplayFrame <DualDisplayFrame
top={ top={
<FrontSurface className="profile-surface" ariaLabel="Hunter profile collection log"> <FrontSurface className="profile-surface" ariaLabel="Hunter profile collection log">
<header className="front-screen-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Collection log</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header> <header className="front-screen-header profile-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Collection log</h1></div><div className="profile-view-tabs" role="tablist" aria-label="Collection view">
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div> <FocusButton id="view-trophies" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "trophies" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "trophies"} onClick={() => setCollectionView("trophies")}>Trophy Case</FocusButton>
<div className="collection-grid"> <FocusButton id="view-stats" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "stats" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "stats"} onClick={() => setCollectionView("stats")}>Boss Stats</FocusButton>
{collection.drops.map((drop) => ( <FocusButton id="view-loot" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "loot" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "loot"} onClick={() => setCollectionView("loot")}>Group Loot</FocusButton>
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}> </div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span> {collectionView === "loot" ? <>
<small>{drop.rarity}</small><strong>{drop.name}</strong> <div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p> <div className="collection-grid">
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small> {collection.drops.map((drop) => (
</article> <article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
))} <span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
</div> <small>{drop.rarity}</small><strong>{drop.name}</strong>
<div className="collection-note"><i></i><span><strong>Boss pets stay individual.</strong><small>{collection.bosses.map((boss) => `${boss.bossName}: ${boss.kills} kills · ${boss.pet.count} pets`).join(" · ")}</small></span></div> <p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p>
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
</article>
))}
</div>
<div className="collection-note"><i></i><span><strong>Boss pets stay individual.</strong><small>Open Trophy Case to inspect every guardian pet.</small></span></div>
</> : collectionView === "trophies" ? <>
<div className="collection-heading trophy-heading"><span><small>Boss pets · 1 in 500 per victory</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{trophiesEarned} / {collection.bosses.length} trophies lit</b></div>
<div className={`trophy-case trophy-count-${collection.bosses.length}`}>
{collection.bosses.map((boss) => {
const owned = boss.pet.count > 0;
return <article key={boss.bossId} className={`boss-trophy ${owned ? "is-owned" : "is-locked"}`} style={{ "--boss-accent": BOSS_DEFINITIONS[boss.bossId].accent } as React.CSSProperties}>
<Suspense fallback={<div className="trophy-portrait trophy-portrait-fallback" role="img" aria-label={`${boss.bossName} portrait`}><span>{BOSS_DEFINITIONS[boss.bossId].icon}</span></div>}><BossTrophyPortrait bossId={boss.bossId} /></Suspense>
<div className="trophy-plaque"><small>{owned ? "Pet secured" : "Pet undiscovered"}</small><strong>{boss.bossName}</strong><span>{boss.kills} kills · {boss.pet.chance}</span></div>
<b className="trophy-state">{owned ? `Owned${boss.pet.count > 1 ? ` ×${boss.pet.count}` : ""}` : "Locked"}</b>
</article>;
})}
</div>
<div className="collection-note trophy-note"><i></i><span><strong>Each guardian keeps its own trophy.</strong><small>Defeat that boss for a 1 in 500 pet roll.</small></span></div>
</> : <>
<div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Overall leaderboards</small><h2>Boss Stats</h2></span><b>Endless best {hunter.stats.highestRogueTrialsEndlessKills} kills</b></div>
<div className="boss-stats-layout">
<section className="boss-stat-selector" aria-label="Boss statistic selection">
<FocusButton id="stat-roguelike" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "roguelike" ? "is-selected" : ""} onClick={() => setSelectedStat("roguelike")}><i></i><span><strong>Roguelike</strong><small>Highest round before defeat</small></span><b>{hunter.stats.highestRoguelikeRound}</b></FocusButton>
<FocusButton id="stat-rogue-trials-endless" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "rogue-trials-endless" ? "is-selected" : ""} onClick={() => setSelectedStat("rogue-trials-endless")}><i></i><span><strong>Trials Endless</strong><small>Most bosses in one run</small></span><b>{hunter.stats.highestRogueTrialsEndlessKills}</b></FocusButton>
{collection.bosses.map((boss) => <FocusButton key={boss.bossId} id={`stat-${boss.bossId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === boss.bossId ? "is-selected" : ""} onClick={() => setSelectedStat(boss.bossId)}><i>{BOSS_DEFINITIONS[boss.bossId].icon}</i><span><strong>{boss.bossName}</strong><small>Lifetime boss kills</small></span><b>{boss.kills}</b></FocusButton>)}
</section>
<section className="leaderboard-panel" aria-label="Overall leaderboard">
<header><span><small>Overall Top 5</small><strong>{selectedStatLabel}</strong></span><b>{selectedStatValue} {selectedStat === "roguelike" ? "round" : "kills"}</b></header>
{leaderboardStatus ? <div className="leaderboard-status">{leaderboardStatus}</div> : <div className="leaderboard-rows">
{leaderboard?.top.length ? leaderboard.top.map((entry) => <div key={`${entry.username}-${entry.slotId}`} className={entry.username === accountId && entry.slotId === hunter.slotId ? "is-you" : ""}><b>#{entry.rank}</b><span><strong>{entry.hunterName}</strong><small>{entry.username}</small></span><em>{entry.value}</em></div>) : <div className="leaderboard-empty">No ranked hunters yet.</div>}
</div>}
<div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{accountId ?? "Offline hunter"}</small></span><em>{selectedStatValue}</em></div>
</section>
</div>
<div className="collection-note trophy-note"><i></i><span><strong>Rankings update with server saves.</strong><small>Top five always shown; your row stays visible at any rank.</small></span></div>
</>}
</FrontSurface> </FrontSurface>
} }
bottom={ bottom={
@@ -465,6 +582,8 @@ function ProfileScreen() {
<span><small>Flawless clears</small><strong>{hunter.stats.flawlessClears}</strong></span> <span><small>Flawless clears</small><strong>{hunter.stats.flawlessClears}</strong></span>
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span> <span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span> <span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
<span><small>Highest roguelike round</small><strong>{hunter.stats.highestRoguelikeRound}</strong></span>
<span><small>Endless best</small><strong>{hunter.stats.highestRogueTrialsEndlessKills}</strong></span>
</div> </div>
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => ( <div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}> <FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
@@ -497,9 +616,23 @@ function GearScreen() {
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion); const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion); const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
const slot = hunter?.gearProgress[selectedOwnerId].slots[selectedSlotId]; const slot = hunter?.gearProgress[selectedOwnerId].slots[selectedSlotId];
const upgradeReadiness = useMemo(() => {
const owners = new Set<GearOwnerId>();
const slots = new Set<string>();
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 recipe = GEAR_RECIPES[selectedOwnerId][selectedSlotId];
const costs = hunter && slot ? gearUpgradeCosts(selectedOwnerId, selectedSlotId, slot.level) : []; 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 infusionChoices = infusionsForOwner(selectedOwnerId);
const selectedInfusion = infusionChoices.find((choice) => choice.id === selectedInfusionId) ?? infusionChoices[0]; const selectedInfusion = infusionChoices.find((choice) => choice.id === selectedInfusionId) ?? infusionChoices[0];
const selectedInfusionCosts = hunter ? infusionCosts(selectedOwnerId, selectedSlotId, selectedInfusion.id) : []; const selectedInfusionCosts = hunter ? infusionCosts(selectedOwnerId, selectedSlotId, selectedInfusion.id) : [];
@@ -585,19 +718,21 @@ function GearScreen() {
<DualDisplayFrame <DualDisplayFrame
top={ top={
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop"> <FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header> <header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
<div className="gear-workshop-layout"> <div className="gear-workshop-layout">
<section className="gear-owner-list" aria-label="Party gear owners"> <section className="gear-owner-list" aria-label="Party gear owners">
{GEAR_OWNER_ORDER.map((ownerId) => { {GEAR_OWNER_ORDER.map((ownerId) => {
const highest = Math.max(...GEAR_SLOT_ORDER.map((slotId) => hunter.gearProgress[ownerId].slots[slotId].level)); const highest = Math.max(...GEAR_SLOT_ORDER.map((slotId) => hunter.gearProgress[ownerId].slots[slotId].level));
return <FocusButton key={ownerId} id={`owner-${ownerId}`} focusedId={controller.focusedId} focus={controller.focus} className={ownerId === selectedOwnerId ? "is-selected" : ""} onClick={() => selectOwner(ownerId)}><span><strong>{GEAR_OWNER_LABELS[ownerId]}</strong><small>Highest slot +{highest}</small></span><b>{ownerId === selectedOwnerId ? "✓" : ""}</b></FocusButton>; const upgradeReady = upgradeReadiness.owners.has(ownerId);
return <FocusButton key={ownerId} id={`owner-${ownerId}`} focusedId={controller.focusedId} focus={controller.focus} aria-label={`${GEAR_OWNER_LABELS[ownerId]}${upgradeReady ? ", upgrade available" : ""}`} className={`${ownerId === selectedOwnerId ? "is-selected" : ""} ${upgradeReady ? "is-upgrade-ready" : ""}`} onClick={() => selectOwner(ownerId)}><span><strong>{GEAR_OWNER_LABELS[ownerId]}</strong><small>Highest slot +{highest}</small></span><b>{ownerId === selectedOwnerId ? "✓" : ""}</b></FocusButton>;
})} })}
</section> </section>
<section className="gear-slot-list" aria-label={`${GEAR_OWNER_LABELS[selectedOwnerId]} gear slots`}> <section className="gear-slot-list" aria-label={`${GEAR_OWNER_LABELS[selectedOwnerId]} gear slots`}>
{GEAR_SLOT_ORDER.map((slotId) => { {GEAR_SLOT_ORDER.map((slotId) => {
const progress = hunter.gearProgress[selectedOwnerId].slots[slotId]; const progress = hunter.gearProgress[selectedOwnerId].slots[slotId];
const slotRecipe = GEAR_RECIPES[selectedOwnerId][slotId]; const slotRecipe = GEAR_RECIPES[selectedOwnerId][slotId];
return <FocusButton key={slotId} id={`slot-${slotId}`} focusedId={controller.focusedId} focus={controller.focus} className={slotId === selectedSlotId ? "is-selected" : ""} onClick={() => selectSlot(slotId)}><i>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}</i><span><strong>{GEAR_SLOT_LABELS[slotId]}</strong><small>{GEAR_STAT_LABELS[slotRecipe.statId]}</small></span><b>+{progress.level}</b></FocusButton>; const upgradeReady = upgradeReadiness.slots.has(`${selectedOwnerId}:${slotId}`);
return <FocusButton key={slotId} id={`slot-${slotId}`} focusedId={controller.focusedId} focus={controller.focus} aria-label={`${GEAR_SLOT_LABELS[slotId]} +${progress.level}${upgradeReady ? ", upgrade available" : ""}`} className={`${slotId === selectedSlotId ? "is-selected" : ""} ${upgradeReady ? "is-upgrade-ready" : ""}`} onClick={() => selectSlot(slotId)}><i>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}</i><span><strong>{GEAR_SLOT_LABELS[slotId]}</strong><small>{GEAR_STAT_LABELS[slotRecipe.statId]}</small></span><b>+{progress.level}</b></FocusButton>;
})} })}
</section> </section>
{workshopMode === "upgrade" ? <article className="gear-preview"> {workshopMode === "upgrade" ? <article className="gear-preview">
@@ -646,7 +781,7 @@ function GearScreen() {
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>; return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
}) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>} }) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
</div> </div>
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : "A · Equip selected passive"}</span><small>Applies at rank 1 next encounter.</small></div> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>} {workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : `${DEFAULT_CONTROLLER_GLYPHS.confirm} · Equip selected passive`}</span><small>Applies at rank 1 next encounter.</small></div> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div> <div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface> </FrontSurface>
} }
@@ -677,7 +812,7 @@ function SettingsScreen() {
<DualDisplayFrame <DualDisplayFrame
top={ top={
<FrontSurface className="settings-surface" ariaLabel="Settings"> <FrontSurface className="settings-surface" ariaLabel="Settings">
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header> <header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
<div className="settings-layout"> <div className="settings-layout">
<section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}></FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}></FocusButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section> <section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}></FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}></FocusButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section>
<section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("largeText", !settings.largeText)} /></section> <section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("largeText", !settings.largeText)} /></section>
@@ -690,9 +825,9 @@ function SettingsScreen() {
<header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header> <header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header>
<div className="controller-map"> <div className="controller-map">
<div className="pad-diagram"><i></i><span><b></b></span><i></i></div> <div className="pad-diagram"><i></i><span><b></b></span><i></i></div>
<div className="face-diagram"><i className="y">Y</i><span><i className="x">X</i><b></b><i className="b">B</i></span><i className="a">A</i></div> <div className="face-diagram"><i className="triangle">{DEFAULT_CONTROLLER_GLYPHS.faceTop}</i><span><i className="square">{DEFAULT_CONTROLLER_GLYPHS.faceLeft}</i><b></b><i className="circle">{DEFAULT_CONTROLLER_GLYPHS.faceRight}</i></span><i className="cross">{DEFAULT_CONTROLLER_GLYPHS.faceBottom}</i></div>
</div> </div>
<div className="mapping-list"><span><b>A</b> Confirm / cast Purify</span><span><b>B</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Start</b> Pause / menu</span></div> <div className="mapping-list"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Confirm / cast Purify</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Right stick</b> Rotate camera</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.start}</b> Pause / menu</span></div>
<div className="control-assurance"><i></i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div> <div className="control-assurance"><i></i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
</FrontSurface> </FrontSurface>
} }
@@ -715,6 +850,8 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectedBoss = BOSS_DEFINITIONS[selectedBossId]; const selectedBoss = BOSS_DEFINITIONS[selectedBossId];
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug]; const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
const isPve = modeId === "roguelike-pve"; const isPve = modeId === "roguelike-pve";
const isRogueTrials = modeId === "rogue-trials";
const isPveRun = isPve || isRogueTrials;
const isDungeon = modeId === "dungeons"; const isDungeon = modeId === "dungeons";
const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId]; const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId];
const visibleBossIds = selectedBossGroup.bossIds; const visibleBossIds = selectedBossGroup.bossIds;
@@ -723,9 +860,9 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]); selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
}; };
const launch = () => { const launch = () => {
if (isPve) return onLaunch(selectRandomBossPair(), "initiate"); if (isPveRun) return onLaunch(selectRandomBossPair(), "initiate");
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug); 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<MenuAction[]>(() => [ const actions = useMemo<MenuAction[]>(() => [
...(isDungeon ? BOSS_GROUPS.map((group, index) => { ...(isDungeon ? BOSS_GROUPS.map((group, index) => {
@@ -775,15 +912,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: "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" } }, { 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 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 const contextRules = isDungeon
? [ ? [
[selectedBoss.name, selectedBoss.summary], [selectedBoss.name, selectedBoss.summary],
[bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing], [bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing],
[bossMechanicName(selectedBoss.mechanicIds[1]), "Controller-ready party behavior and full lower-display support."], [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."],
["Endless choice", "After the trio falls, quit with the clear or continue while every dead boss is replaced."],
]
: isPve : isPve
? [ ? [
["Randomized pair", "Two distinct bosses are selected only when the run begins."], ["Randomized pair", "Two distinct bosses are selected only when the run begins."],
@@ -799,7 +942,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
<DualDisplayFrame <DualDisplayFrame
top={ top={
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}> <FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header> <header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>} {!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && ( {isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter"> <div className="boss-picker" aria-label="Choose boss encounter">
@@ -847,7 +990,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
{DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)} {DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)}
</div> </div>
)} )}
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · A</small></FocusButton> <FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · {DEFAULT_CONTROLLER_GLYPHS.confirm}</small></FocusButton>
{message && <div className="front-notice">{message}</div>} {message && <div className="front-notice">{message}</div>}
</FrontSurface> </FrontSurface>
} }
+44 -180
View File
@@ -6,7 +6,19 @@ import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { ARENA_CENTER, clampToArena } from "../game/arena"; import { ARENA_CENTER, clampToArena } from "../game/arena";
import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog"; 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 { 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 { import {
isActorAnimationOneShot, isActorAnimationOneShot,
shouldStartActorAnimation, shouldStartActorAnimation,
@@ -17,43 +29,8 @@ import { useGameStore } from "../game/store";
import type { BossId, MemberId, PulseKind } from "../game/types"; import type { BossId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom"; import { BossRoom } from "./BossRoom";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators"; 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<Exclude<BossId,
| "bulldrome"
| "sandglass-scorpion"
| "cragclaw-crab"
| "mournveil-ghost"
| "crownshard-golem"
| "crystal-bat-matriarch"
>, 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<MemberId, string> = { const PARTY_MODEL_URLS: Record<MemberId, string> = {
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href, 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, 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 castingUntil = useRef(0);
const instantCastTrigger = useRef(0); const instantCastTrigger = useRef(0);
const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []); const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []);
const cameraOrbit = useRef<CameraOrbitState>({ yaw: DEFAULT_CAMERA_YAW, pitch: DEFAULT_CAMERA_PITCH });
const cameraRelativeMovement = useRef<PlanarMovement>({ x: 0, z: 0 });
useEffect(() => { useEffect(() => {
const start = useGameStore.getState().partyPositions.aelia; const start = useGameStore.getState().partyPositions.aelia;
@@ -451,7 +430,11 @@ function PlayerCharacter() {
const nudgeX = Number(key === "d") - Number(key === "a"); const nudgeX = Number(key === "d") - Number(key === "a");
const nudgeZ = Number(key === "s") - Number(key === "w"); const nudgeZ = Number(key === "s") - Number(key === "w");
if (!nudgeX && !nudgeZ) return; 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.x = next[0];
group.current.position.z = next[1]; group.current.position.z = next[1];
setPlayerPosition([group.current.position.x, group.current.position.z]); 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")); inputX = Number(keys.current.has("d")) - Number(keys.current.has("a"));
inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w")); inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w"));
const controller = getControllerMovement(); const controller = getControllerMovement();
inputX += controller.x; inputX += controller.moveX;
inputZ += controller.y; 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); const length = Math.hypot(inputX, inputZ);
if (length > 0.05) { if (length > 0.05) {
const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length); const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length);
@@ -518,9 +508,20 @@ function PlayerCharacter() {
: "idle"; : "idle";
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16); 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.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; broadcastTimer.current += delta;
if (broadcastTimer.current > 0.15) { if (broadcastTimer.current > 0.15) {
@@ -651,6 +652,7 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
const motion = current.motion; const motion = current.motion;
targetPosition.set(motion.position[0], 0.03, motion.position[1]); targetPosition.set(motion.position[0], 0.03, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta)); group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
if (!bossCanTrackTarget(current.boss.hp)) return;
if (motion.mode === "stacking") { if (motion.mode === "stacking") {
group.current.rotation.y += (Math.PI * 2 / 5) * delta; group.current.rotation.y += (Math.PI * 2 / 5) * delta;
@@ -678,145 +680,6 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
); );
} }
type AlternateBossKind = Exclude<ReturnType<typeof useGameStore.getState>["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<AlternateBossKind, AlternateBossConfig> = {
"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<typeof useGameStore.getState>["bossMotion"]) { function alternateBossClip(kind: AlternateBossKind, motion: ReturnType<typeof useGameStore.getState>["bossMotion"]) {
const config = ALTERNATE_BOSS_CONFIG[kind]; 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; const floatingHeight = config.floating ? 0.2 : 0.03;
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]); 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)); group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
if (!bossCanTrackTarget(current.boss.hp)) return;
let targetAngle = Math.atan2( let targetAngle = Math.atan2(
state.partyPositions.brann[0] - motion.position[0], state.partyPositions.brann[0] - motion.position[0],
+26 -14
View File
@@ -5,6 +5,7 @@ import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { bossRoomFor } from "../game/bossRooms"; import { bossRoomFor } from "../game/bossRooms";
import { tankAuraProtects } from "../game/partyCombat"; import { tankAuraProtects } from "../game/partyCombat";
import { BuffDraftPanel } from "./BuffDraftPanel"; import { BuffDraftPanel } from "./BuffDraftPanel";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))); const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
@@ -81,7 +82,7 @@ function BossBar() {
if (phase === "briefing") return null; if (phase === "briefing") return null;
const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)]; const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)];
return ( return (
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-dual" : ""}`}> <div className={`boss-bar-wrap ${bosses.length > 1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""}`}>
{bosses.map((entry) => <div className="boss-bar-entry" key={entry.id}> {bosses.map((entry) => <div className="boss-bar-entry" key={entry.id}>
<div className="boss-name"><span>Vault Beast</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div> <div className="boss-name"><span>Vault Beast</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div>
<div className="boss-bar"><i style={{ width: `${(entry.hp / entry.maxHp) * 100}%` }} /></div> <div className="boss-bar"><i style={{ width: `${(entry.hp / entry.maxHp) * 100}%` }} /></div>
@@ -108,6 +109,10 @@ function EncounterCallout() {
function PhaseOverlay() { function PhaseOverlay() {
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const primaryBoss = useGameStore((state) => state.boss); const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses); const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />; if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
@@ -115,29 +120,34 @@ function PhaseOverlay() {
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]); const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const room = bossRoomFor(primaryBoss.id); const room = bossRoomFor(primaryBoss.id);
const bossNames = bosses.map((boss) => boss.name).join(" & "); const bossNames = bosses.map((boss) => boss.name).join(" & ");
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
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; if (phase === "combat") return null;
const title = phase === "briefing" const title = phase === "briefing"
? room.name ? room.name
: phase === "victory" : phase === "victory"
? `${bossNames} Broken` ? showEndlessChoice ? "Rogue Trials Cleared" : `${bossNames} Broken`
: "Party Broken"; : endlessDefeat ? "Endless Run Ended" : "Party Broken";
const eyebrow = phase === "briefing" const eyebrow = phase === "briefing"
? `${bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial} · ${room.biome}` ? `${briefingMode} · ${room.biome}`
: phase === "victory" : phase === "victory"
? "Encounter Complete" ? showEndlessChoice ? "Endless Path Unlocked" : "Encounter Complete"
: "Encounter Failed"; : endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
const copy = phase === "briefing" const copy = phase === "briefing"
? definitions.map((boss) => boss.briefing).join(" ") ? definitions.map((boss) => boss.briefing).join(" ")
: phase === "victory" : phase === "victory"
? "Five entered. Five endured." ? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : "Five entered. Five endured."
: definitions.map((boss) => boss.failure).join(" "); : endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
return ( return (
<div className={`phase-overlay phase-${phase}`}> <div className={`phase-overlay phase-${phase}`}>
<div className="phase-sigil"></div> <div className="phase-sigil"></div>
<span>{eyebrow}</span> <span>{eyebrow}</span>
<h1>{title}</h1> <h1>{title}</h1>
<p>{copy}</p> <p>{copy}</p>
<small>{phase === "briefing" ? "Begin from lower display" : "Restart from lower display"}</small> <small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Continue or Quit on lower display" : "Restart from lower display"}</small>
</div> </div>
); );
} }
@@ -164,15 +174,15 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
onFocus={() => setPauseSelection("resume")} onFocus={() => setPauseSelection("resume")}
onPointerEnter={() => setPauseSelection("resume")} onPointerEnter={() => setPauseSelection("resume")}
onClick={() => setPaused(false)} onClick={() => setPaused(false)}
>Resume <small>START / ESC</small></button> >Resume <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>
<button <button
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`} className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
onFocus={() => setPauseSelection("exit")} onFocus={() => setPauseSelection("exit")}
onPointerEnter={() => setPauseSelection("exit")} onPointerEnter={() => setPauseSelection("exit")}
onClick={exit} onClick={exit}
>Return to main menu <small>A</small></button> >Return to main menu <small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small></button>
</div> </div>
<footer><b> / </b> Choose <i /> <b>A / ENTER</b> Confirm</footer> <footer><b> / </b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> Confirm</footer>
</div> </div>
</div> </div>
); );
@@ -183,6 +193,8 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
const bossCount = useGameStore((state) => state.additionalBosses.length + 1); const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
const round = useGameStore((state) => state.round); const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode); const runMode = useGameStore((state) => state.runMode);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const setPaused = useGameStore((state) => state.setPaused); const setPaused = useGameStore((state) => state.setPaused);
return ( return (
<section className="display top-display" aria-label="Main game viewport"> <section className="display top-display" aria-label="Main game viewport">
@@ -193,11 +205,11 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
<div className="top-hud"> <div className="top-hud">
<CompactParty /> <CompactParty />
<BossBar /> <BossBar />
<div className="objective-chip"><span>{runMode === "roguelike" ? `Round ${round}` : "Objective"}</span><strong>{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div> <div className="objective-chip"><span>{endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<EncounterCallout /> <EncounterCallout />
<CastingBar /> <CastingBar />
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div> <div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>START / ESC</small></button>} {onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div> </div>
<PhaseOverlay /> <PhaseOverlay />
<PauseOverlay onExit={onExit} /> <PauseOverlay onExit={onExit} />
+135 -5
View File
@@ -1,10 +1,12 @@
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { Html } from "@react-three/drei"; 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 * as THREE from "three";
import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool"; 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 { 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 CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2); 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.29, 0.04)
.lineTo(-0.13, 0.04) .lineTo(-0.13, 0.04)
.lineTo(-0.13, -0.32); .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<typeof useGameStore.getState>; type GameStoreState = ReturnType<typeof useGameStore.getState>;
function motionAt(state: GameStoreState, bossIndex: number) { function motionAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion; 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<THREE.Group>(null);
const fill = useRef<THREE.MeshBasicMaterial>(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 (
<group ref={group} position={[origin[0], height, origin[1]]} rotation={[0, angleTo(origin, destination) + Math.PI, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]} scale={1.28} renderOrder={30}>
<shapeGeometry args={[INWARD_ARROW_SHAPE]} />
<meshBasicMaterial color={SOUL_SIPHON_GUIDANCE_OUTLINE} transparent opacity={0.92} depthTest={false} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={31}>
<shapeGeometry args={[INWARD_ARROW_SHAPE]} />
<meshBasicMaterial ref={fill} color={color} transparent opacity={1} depthTest={false} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
function SoulSiphonGuidanceIndicator() {
const guidance = useGameStore(activeSoulSiphonGuidance);
const playerPosition = useGameStore((state) => state.partyPositions.aelia);
if (!guidance) return null;
return (
<WorldDirectionIndicator
origin={playerPosition}
destination={guidance.wardPosition}
height={SOUL_SIPHON_GUIDANCE_HEIGHT}
color={SOUL_SIPHON_GUIDANCE_COLOR}
/>
);
}
export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) { export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase); const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode); const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode);
@@ -644,14 +725,63 @@ const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[]
PooledMechanicIndicators, PooledMechanicIndicators,
]; ];
function BossMechanicIndicatorSet({ bossIndex }: { bossIndex: number }) {
const defeated = useGameStore((state) => bossHpAt(state, bossIndex) <= 0);
const group = useRef<THREE.Group>(null);
const opacity = useRef(defeated ? 0 : 1);
const patchedMeshes = useRef(new WeakSet<THREE.Mesh>());
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 (
<group ref={group}>
{retainIndicators && BOSS_MECHANIC_INDICATORS.map((Indicator) => <Indicator key={Indicator.name} bossIndex={bossIndex} />)}
</group>
);
}
export function BossMechanicIndicators() { export function BossMechanicIndicators() {
const bossCount = useGameStore((state) => state.additionalBosses.length + 1); const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
return ( return (
<> <>
<SoulSiphonGuidanceIndicator />
{Array.from({ length: bossCount }, (_, bossIndex) => ( {Array.from({ length: bossCount }, (_, bossIndex) => (
<group key={bossIndex}> <BossMechanicIndicatorSet key={bossIndex} bossIndex={bossIndex} />
{BOSS_MECHANIC_INDICATORS.map((Indicator) => <Indicator key={Indicator.name} bossIndex={bossIndex} />)}
</group>
))} ))}
</> </>
); );
@@ -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);
});
});
+10
View File
@@ -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);
}
+24 -39
View File
@@ -1,53 +1,38 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { StorageAdapter } from "./saveRepository";
import { AccountRepository } from "./accountRepository"; import { AccountRepository } from "./accountRepository";
import { OnlineApiError, type OnlineRepository } from "./onlineRepository";
function memoryStorage(): StorageAdapter { function onlineStub(overrides: Partial<OnlineRepository> = {}): OnlineRepository {
const data = new Map<string, string>();
return { return {
getItem: (key) => data.get(key) ?? null, register: vi.fn(async (username: string) => ({ id: 1, username })),
setItem: (key, value) => { data.set(key, value); }, 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", () => { describe("AccountRepository", () => {
it("requires both a username and password", async () => { it("requires both username and password before contacting server", async () => {
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt"); const online = onlineStub();
const repository = new AccountRepository(online);
await expect(repository.create("", "secret")).resolves.toEqual({ ok: false, reason: "missing-credentials" }); 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" }); 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 () => { it("creates and authenticates real server accounts", async () => {
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt"); const repository = new AccountRepository(onlineStub());
await expect(repository.create("Wayfinder", "long-password")).resolves.toEqual({ ok: true, username: "Wayfinder" });
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: false, reason: "account-not-found" }); await expect(repository.authenticate("Wayfinder", "long-password")).resolves.toEqual({ ok: true, username: "Wayfinder" });
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("rejects an incorrect password and duplicate account names", async () => { it("maps server conflicts, invalid credentials, and outages", async () => {
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt"); const conflict = new AccountRepository(onlineStub({ register: vi.fn(async () => { throw new OnlineApiError("exists", 409); }) }));
await repository.create("Wayfinder", "correct"); 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(repository.authenticate("wayfinder", "wrong")).resolves.toEqual({ ok: false, reason: "invalid-password" }); await expect(invalid.authenticate("Wayfinder", "wrong-password")).resolves.toMatchObject({ ok: false, reason: "invalid-password" });
await expect(repository.create(" wayfinder ", "another")).resolves.toEqual({ ok: false, reason: "account-exists" }); 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" });
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");
}); });
}); });
+23 -115
View File
@@ -1,132 +1,40 @@
import type { StorageAdapter } from "./saveRepository"; import { OnlineApiError, OnlineRepository, onlineRepository } from "./onlineRepository";
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<string, AccountRecord>;
type PasswordHasher = (password: string, salt: string) => Promise<string>;
export type AccountResult = export type AccountResult =
| { ok: true; username: string } | { 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<string, string>(); function failure(error: unknown): Extract<AccountResult, { ok: false }> {
const fallbackStorage: StorageAdapter = { if (!(error instanceof OnlineApiError)) return { ok: false, reason: "server-unavailable" };
getItem: (key) => fallbackMemory.get(key) ?? null, if (error.status === 0 || error.status >= 500) return { ok: false, reason: "server-unavailable", message: error.message };
setItem: (key, value) => { fallbackMemory.set(key, value); }, 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 browserStorage(): StorageAdapter {
try {
if (typeof localStorage !== "undefined") return localStorage;
} catch {
// Android WebView can deny storage before its host is ready.
}
return fallbackStorage;
} }
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 { export class AccountRepository {
constructor( constructor(private readonly online: OnlineRepository = onlineRepository) {}
private readonly storage: StorageAdapter = browserStorage(),
private readonly hasher: PasswordHasher = hashPassword,
private readonly createSalt: () => string = randomSalt,
) {}
async create(usernameInput: string, password: string): Promise<AccountResult> {
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" };
async create(username: string, password: string): Promise<AccountResult> {
if (!username.trim() || !password) return { ok: false, reason: "missing-credentials" };
try { try {
const salt = this.createSalt(); const account = await this.online.register(username, password);
accounts[canonical] = { username, salt, passwordHash: await this.hasher(password, salt) }; return { ok: true, username: account.username };
this.storage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); } catch (error) {
return { ok: true, username }; return failure(error);
} catch {
return { ok: false, reason: "storage-unavailable" };
} }
} }
async authenticate(usernameInput: string, password: string): Promise<AccountResult> { async authenticate(username: string, password: string): Promise<AccountResult> {
const canonical = canonicalUsername(usernameInput); if (!username.trim() || !password) return { ok: false, reason: "missing-credentials" };
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
const account = this.read()[canonical];
if (!account) return { ok: false, reason: "account-not-found" };
try { try {
const passwordHash = await this.hasher(password, account.salt); const account = await this.online.login(username, password);
return passwordHash === account.passwordHash return { ok: true, username: account.username };
? { ok: true, username: account.username } } catch (error) {
: { ok: false, reason: "invalid-password" }; return failure(error);
} catch {
return { ok: false, reason: "storage-unavailable" };
} }
} }
private read() { session() { return this.online.session(); }
return parseAccounts(this.storage.getItem(ACCOUNTS_KEY)); logout() { return this.online.logout(); }
}
} }
+9
View File
@@ -7,6 +7,7 @@ import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog";
describe("game mode configuration", () => { describe("game mode configuration", () => {
it("separates randomized PVE from selectable Dungeons", () => { it("separates randomized PVE from selectable Dungeons", () => {
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE"); expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
expect(MODE_COPY["rogue-trials"].detail).toContain("Endless");
expect(MODE_COPY.dungeons.title).toBe("Dungeons"); 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);
});
}); });
+10 -1
View File
@@ -70,7 +70,14 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
title: "PVE", title: "PVE",
description: "Enter without an encounter briefing, adapt to two randomized guardians, and build toward a full roguelike run.", description: "Enter without an encounter briefing, adapt to two randomized guardians, and build toward a full roguelike run.",
detail: "Two bosses selected when the run begins", detail: "Two bosses selected when the run begins",
status: "Playable prototype", status: "Playable now",
},
"rogue-trials": {
eyebrow: "14 hunters · five-round PVE trial",
title: "Rogue Trials",
description: "Build through four randomized dual-boss rounds, defeat an unseen trio, then leave with the clear or continue into endless combat.",
detail: "Endless mode replaces every fallen boss and tracks your best kill count",
status: "Playable now",
}, },
dungeons: { dungeons: {
eyebrow: "14 hunters · chosen encounter", eyebrow: "14 hunters · chosen encounter",
@@ -131,6 +138,8 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
alliesSaved: 0, alliesSaved: 0,
healingDone: 0, healingDone: 0,
bossKills: {}, bossKills: {},
highestRoguelikeRound: 0,
highestRogueTrialsEndlessKills: 0,
}, },
materials: [] as MaterialStack[], materials: [] as MaterialStack[],
collectionLog: createEmptyCollectionLog(), collectionLog: createEmptyCollectionLog(),
+156
View File
@@ -0,0 +1,156 @@
import { Capacitor } from "@capacitor/core";
import type { HunterSave, SaveSlotId } from "./types";
import type { BossId } from "../game/types";
export interface OnlineAccount {
id: number;
username: string;
}
export interface OnlineSaveSlot {
slotId: SaveSlotId;
save: HunterSave;
updatedAt: string;
}
export interface LeaderboardEntry {
rank: number;
username: string;
hunterName: string;
slotId: SaveSlotId;
value: number;
}
export interface LeaderboardResult {
kind: "boss" | "roguelike" | "rogue-trials-endless";
bossId?: BossId;
top: LeaderboardEntry[];
current: LeaderboardEntry | null;
}
interface TokenStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
type Requester = typeof fetch;
const TOKEN_KEY = "i-want-to-heal:auth-token:v1";
const PRODUCTION_API_URL = "https://iwanttoheal.phenomrom.com";
function defaultApiBaseUrl() {
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "");
if (configured) return configured;
return Capacitor.isNativePlatform() ? PRODUCTION_API_URL : "";
}
function browserStorage(): TokenStorage {
if (typeof localStorage !== "undefined") return localStorage;
const memory = new Map<string, string>();
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<T>(path: string, init: RequestInit = {}): Promise<T> {
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<OnlineAccount> {
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<OnlineAccount> {
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<OnlineAccount | null> {
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<void> {
try { await this.request("/api/auth/logout", { method: "POST" }); }
finally { this.storage.removeItem(TOKEN_KEY); }
}
async listSaves(): Promise<OnlineSaveSlot[]> {
return (await this.request<{ slots: OnlineSaveSlot[] }>("/api/saves")).slots;
}
async writeSave(save: HunterSave): Promise<HunterSave> {
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<HunterSave | null> {
return (await this.request<{ save: HunterSave | null }>(`/api/saves/${slotId}`)).save;
}
bossLeaderboard(bossId: BossId, slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/boss/${encodeURIComponent(bossId)}?slot=${slotId}`);
}
roguelikeLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/roguelike?slot=${slotId}`);
}
rogueTrialsEndlessLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/rogue-trials-endless?slot=${slotId}`);
}
}
export const onlineRepository = new OnlineRepository();
+20 -49
View File
@@ -16,7 +16,7 @@ describe("SaveRepository", () => {
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z"); const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
repository.create(2, "Seraphine"); 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.map((slot) => slot.id)).toEqual([1, 2, 3]);
expect(slots[1].local?.updatedAt).toBe("2026-07-10T12:00:00.000Z"); expect(slots[1].local?.updatedAt).toBe("2026-07-10T12:00:00.000Z");
expect(slots[1].local?.hunterName).toBe("Seraphine"); expect(slots[1].local?.hunterName).toBe("Seraphine");
@@ -34,41 +34,30 @@ describe("SaveRepository", () => {
healers: { ...save.healers, druid: { ...save.healers.druid, level: 99 } }, 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[0].local?.healers.druid.level).toBe(1);
expect(slots[2].local?.healers.druid.level).toBe(99); expect(slots[2].local?.healers.druid.level).toBe(99);
expect(slots[2].local?.slotId).toBe(3); 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"; let now = "2026-07-10T12:00:00.000Z";
const repository = new SaveRepository(memoryStorage(), () => now); const repository = new SaveRepository(memoryStorage(), () => now);
repository.create(1, "Aelia"); const serverSave = repository.create(1, "Aelia");
now = "2026-07-10T13:00:00.000Z"; serverSave.healers.priest.level = 40;
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);
now = "2026-07-10T14:00:00.000Z"; now = "2026-07-10T14:00:00.000Z";
repository.download(1, "healer@example.com"); repository.replaceLocal(serverSave);
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(1); expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40);
expect(repository.list("healer@example.com")[0].local?.updatedAt).toBe(now); 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"); const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
repository.create(1, "Aelia"); repository.create(1, "Aelia");
repository.upload(1, "healer");
repository.deleteLocal(1); repository.deleteLocal(1);
const slot = repository.listLocal()[0];
const slot = repository.list("healer")[0];
expect(slot.local).toBeNull(); expect(slot.local).toBeNull();
expect(slot.online).not.toBeNull(); expect(slot.online).toBeNull();
}); });
it("keeps class progression and inventories independent under one hunter name", () => { 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.hunterName).toBe("Aelia");
expect(save.activeClassId).toBe("druid"); expect(save.activeClassId).toBe("druid");
expect(save.healers.druid.level).toBe(8); expect(save.healers.druid.level).toBe(8);
@@ -102,7 +91,7 @@ describe("SaveRepository", () => {
activeClassId: "druid", activeClassId: "druid",
playSeconds: 999, playSeconds: 999,
healers: Object.fromEntries(Object.entries(created.healers).map(([id, healer]) => [id, { ...healer, level: 27 }])), 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" }], 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 } }, collectionLog: { dropsFound: { "legacy-boss-coin": 99 }, petsFound: { "bulldrome-pet": 1 } },
gearProgress: Object.fromEntries(Object.entries(created.gearProgress).map(([id, owner]) => [id, { gearProgress: Object.fromEntries(Object.entries(created.gearProgress).map(([id, owner]) => [id, {
@@ -113,39 +102,19 @@ describe("SaveRepository", () => {
} as Record<string, unknown>; } as Record<string, unknown>;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy })); 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.schemaVersion).toBe(5);
expect(migrated.hunterName).toBe("Legacy"); expect(migrated.hunterName).toBe("Legacy");
expect(migrated.activeClassId).toBe("priest"); expect(migrated.activeClassId).toBe("priest");
expect(migrated.playSeconds).toBe(0); expect(migrated.playSeconds).toBe(0);
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true); 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, highestRogueTrialsEndlessKills: 0 });
expect(migrated.materials).toEqual([]); expect(migrated.materials).toEqual([]);
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} }); 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(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); 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", () => { it("preserves valid v5 progression and group-drop inventory", () => {
const storage = memoryStorage(); const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
@@ -153,14 +122,16 @@ describe("SaveRepository", () => {
const drop = groupDrop("charge", "veteran"); const drop = groupDrop("charge", "veteran");
created.healers.priest.level = 8; created.healers.priest.level = 8;
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } }; created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
created.stats.highestRogueTrialsEndlessKills = 14;
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }]; created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } }; created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); 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.schemaVersion).toBe(5);
expect(migrated.healers.priest.level).toBe(8); expect(migrated.healers.priest.level).toBe(8);
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 }); expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14);
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 }); expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
expect(migrated.collectionLog).toEqual(created.collectionLog); expect(migrated.collectionLog).toEqual(created.collectionLog);
}); });
@@ -176,7 +147,7 @@ describe("SaveRepository", () => {
created.gearProgress.brann.passiveInfusionId = "deep-wells" as never; created.gearProgress.brann.passiveInfusionId = "deep-wells" as never;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); 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.schemaVersion).toBe(5);
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary"); expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull(); expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull();
@@ -192,7 +163,7 @@ describe("SaveRepository", () => {
const created = repository.create(1, "Infused"); const created = repository.create(1, "Infused");
created.gearProgress.priest.passiveInfusionId = passiveId; created.gearProgress.priest.passiveInfusionId = passiveId;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); 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);
} }
}); });
}); });
+8 -21
View File
@@ -15,7 +15,6 @@ export interface StorageAdapter {
type SaveMap = Partial<Record<SaveSlotId, HunterSave>>; type SaveMap = Partial<Record<SaveSlotId, HunterSave>>;
const LOCAL_KEY = "i-want-to-heal:saves:local:v1"; 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 SLOT_IDS: SaveSlotId[] = [1, 2, 3];
const fallbackMemory = new Map<string, string>(); const fallbackMemory = new Map<string, string>();
@@ -150,6 +149,8 @@ function normalizeSave(value: unknown): HunterSave | null {
alliesSaved: Math.max(0, candidate.stats?.alliesSaved ?? 0), alliesSaved: Math.max(0, candidate.stats?.alliesSaved ?? 0),
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0), healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
bossKills, bossKills,
highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)),
highestRogueTrialsEndlessKills: Math.max(0, Math.floor(candidate.stats?.highestRogueTrialsEndlessKills ?? 0)),
}, },
materials: normalizeMaterials(candidate.materials, collectionLog), materials: normalizeMaterials(candidate.materials, collectionLog),
collectionLog, collectionLog,
@@ -181,10 +182,9 @@ export class SaveRepository {
private readonly now: () => string = () => new Date().toISOString(), private readonly now: () => string = () => new Date().toISOString(),
) {} ) {}
list(accountId: string | null): SaveSlotState[] { listLocal(): SaveSlotState[] {
const local = this.read(LOCAL_KEY); 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: null }));
return SLOT_IDS.map((id) => ({ id, local: local[id] ?? null, online: online[id] ?? null }));
} }
create(slotId: SaveSlotId, hunterName: string): HunterSave { create(slotId: SaveSlotId, hunterName: string): HunterSave {
@@ -223,23 +223,10 @@ export class SaveRepository {
return copy; return copy;
} }
upload(slotId: SaveSlotId, accountId: string): HunterSave | null { replaceLocal(save: HunterSave): HunterSave {
const local = this.read(LOCAL_KEY)[slotId]; const normalized = { ...cloneSave(save), updatedAt: this.now() };
if (!local) return null; this.setLocal(normalized);
const cloud = this.read(CLOUD_KEY(accountId)); return normalized;
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;
} }
private setLocal(save: HunterSave): void { private setLocal(save: HunterSave): void {
+142 -33
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data"; import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
import { SaveRepository } from "./saveRepository"; import { SaveRepository } from "./saveRepository";
import { AccountRepository, type AccountResult } from "./accountRepository"; import { AccountRepository, type AccountResult } from "./accountRepository";
import { onlineRepository, type OnlineSaveSlot } from "./onlineRepository";
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types"; import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
import type { AbilityId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types"; import type { AbilityId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types";
import { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike"; import { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike";
@@ -12,10 +13,23 @@ import {
infusionsForOwner, infusionsForOwner,
} from "../game/progression/infusions"; } from "../game/progression/infusions";
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot"; import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats";
const repository = new SaveRepository(); const repository = new SaveRepository();
const accounts = new AccountRepository(); const accounts = new AccountRepository();
const SETTINGS_KEY = "i-want-to-heal:settings:v1"; const SETTINGS_KEY = "i-want-to-heal:settings:v1";
const onlineSaveQueues = new Map<SaveSlotId, Promise<HunterSave>>();
function writeServerSaveSerially(save: HunterSave): Promise<HunterSave> {
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 { function loadSettings(): GameSettings {
try { try {
@@ -38,14 +52,30 @@ function accountNotice(result: Extract<AccountResult, { ok: false }>, action: "s
switch (result.reason) { switch (result.reason) {
case "missing-credentials": return "Enter both username and password."; case "missing-credentials": return "Enter both username and password.";
case "account-exists": return "Account already exists. Sign in with its 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 "invalid-password": return "Username or password is incorrect.";
case "storage-unavailable": return action === "create" case "server-unavailable": return "Online server is unreachable. Continue offline or try again.";
? "Account could not be saved on this device. Continue offline or try again." case "invalid-request": return result.message ?? (action === "create" ? "Account could not be created." : "Sign-in failed.");
: "Account could not be verified on this device. Continue offline or try again.";
} }
} }
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 { export interface FrontendState {
screen: AppScreen; screen: AppScreen;
accountId: string | null; accountId: string | null;
@@ -64,6 +94,7 @@ export interface FrontendState {
recentRewards: BossRewardAward[]; recentRewards: BossRewardAward[];
settings: GameSettings; settings: GameSettings;
notice: string; notice: string;
restoreSession: () => Promise<boolean>;
signIn: (username: string, password: string) => Promise<boolean>; signIn: (username: string, password: string) => Promise<boolean>;
createAccount: (username: string, password: string) => Promise<boolean>; createAccount: (username: string, password: string) => Promise<boolean>;
continueOffline: () => void; continueOffline: () => void;
@@ -74,8 +105,8 @@ export interface FrontendState {
playSlot: (slotId: SaveSlotId) => void; playSlot: (slotId: SaveSlotId) => void;
deleteSlot: (slotId: SaveSlotId) => void; deleteSlot: (slotId: SaveSlotId) => void;
copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void; copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void;
uploadSlot: (slotId: SaveSlotId) => void; uploadSlot: (slotId: SaveSlotId) => Promise<void>;
downloadSlot: (slotId: SaveSlotId) => void; downloadSlot: (slotId: SaveSlotId) => Promise<void>;
selectMode: (mode: GameModeId) => void; selectMode: (mode: GameModeId) => void;
selectBoss: (bossId: BossId) => void; selectBoss: (bossId: BossId) => void;
selectDifficulty: (difficultySlug: DifficultySlug) => void; selectDifficulty: (difficultySlug: DifficultySlug) => void;
@@ -93,6 +124,8 @@ export interface FrontendState {
updateSetting: <K extends keyof GameSettings>(key: K, value: GameSettings[K]) => void; updateSetting: <K extends keyof GameSettings>(key: K, value: GameSettings[K]) => void;
touchActiveSave: () => void; touchActiveSave: () => void;
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null; recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
recordRoguelikeDefeat: (round: number) => void;
recordRogueTrialsEndlessDefeat: (bossKills: number) => void;
clearRecentRewards: () => void; clearRecentRewards: () => void;
clearNotice: () => void; clearNotice: () => void;
} }
@@ -104,7 +137,7 @@ function activeSave(slots: SaveSlotState[], activeSlotId: SaveSlotId | null): Hu
export const useFrontendStore = create<FrontendState>((set, get) => ({ export const useFrontendStore = create<FrontendState>((set, get) => ({
screen: "login", screen: "login",
accountId: null, accountId: null,
slots: repository.list(null), slots: repository.listLocal(),
selectedSlotId: 1, selectedSlotId: 1,
activeSlotId: null, activeSlotId: null,
selectedMode: "roguelike-pve", selectedMode: "roguelike-pve",
@@ -120,14 +153,32 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
settings: loadSettings(), settings: loadSettings(),
notice: "", 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) => { signIn: async (username, password) => {
const result = await accounts.authenticate(username, password); const result = await accounts.authenticate(username, password);
if (!result.ok) { if (!result.ok) {
set({ notice: accountNotice(result, "sign-in") }); set({ notice: accountNotice(result, "sign-in") });
return false; return false;
} }
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Online sync connected as ${result.username}.` }); try {
return true; 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) => { createAccount: async (username, password) => {
const result = await accounts.create(username, password); const result = await accounts.create(username, password);
@@ -135,11 +186,20 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
set({ notice: accountNotice(result, "create") }); set({ notice: accountNotice(result, "create") });
return false; return false;
} }
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Account created. Online sync connected as ${result.username}.` }); try {
return true; 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: "" }), navigate: (screen) => set({ screen, notice: "" }),
selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }), selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }),
createSlot: (slotId, rawHunterName) => { createSlot: (slotId, rawHunterName) => {
@@ -149,18 +209,18 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
return false; return false;
} }
repository.create(slotId, hunterName); 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; return true;
}, },
playSlot: (slotId) => { playSlot: (slotId) => {
const local = repository.touch(slotId); const local = repository.touch(slotId);
if (!local) return; 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) => { deleteSlot: (slotId) => {
repository.deleteLocal(slotId); repository.deleteLocal(slotId);
set((state) => ({ set((state) => ({
slots: repository.list(state.accountId), slots: refreshLocalSlots(state.slots),
activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId, activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId,
notice: `Local slot ${slotId} deleted. Online copy preserved.`, notice: `Local slot ${slotId} deleted. Online copy preserved.`,
})); }));
@@ -168,19 +228,36 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
copySlot: (sourceId, targetId) => { copySlot: (sourceId, targetId) => {
const copy = repository.copyLocal(sourceId, targetId); const copy = repository.copyLocal(sourceId, targetId);
if (!copy) return; 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(); const { accountId } = get();
if (!accountId) return set({ notice: "Sign in before syncing online." }); if (!accountId) return set({ notice: "Sign in before syncing online." });
const uploaded = repository.upload(slotId, accountId); const local = repository.listLocal().find((slot) => slot.id === slotId)?.local;
set({ slots: repository.list(accountId), notice: uploaded ? `Slot ${slotId} synced to server.` : "No offline save to sync." }); 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(); const { accountId } = get();
if (!accountId) return set({ notice: "Sign in before downloading an online save." }); if (!accountId) return set({ notice: "Sign in before downloading an online save." });
const downloaded = repository.download(slotId, accountId); try {
set({ slots: repository.list(accountId), notice: downloaded ? `Slot ${slotId} overwritten with online version.` : "No online version exists for this slot." }); 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: "" }), selectMode: (selectedMode) => set({ selectedMode, screen: "mode", notice: "" }),
selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }), selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }),
@@ -218,7 +295,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
return save; return save;
} }
}); });
set({ slots: repository.list(accountId), notice: message }); set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
return upgraded; return upgraded;
}, },
equipSelectedInfusion: () => { equipSelectedInfusion: () => {
@@ -238,7 +315,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
return save; return save;
} }
}); });
set({ slots: repository.list(accountId), notice: message }); set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
return equipped; return equipped;
}, },
equipPassiveInfusion: (passiveId) => { equipPassiveInfusion: (passiveId) => {
@@ -257,7 +334,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
return save; return save;
} }
}); });
set({ slots: repository.list(accountId), notice: message }); set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
return equipped; return equipped;
}, },
selectHealerClass: (classId) => { selectHealerClass: (classId) => {
@@ -266,7 +343,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
const updated = repository.updateLocal(activeSlotId, (save) => ({ ...save, activeClassId: classId })); const updated = repository.updateLocal(activeSlotId, (save) => ({ ...save, activeClassId: classId }));
if (!updated) return; if (!updated) return;
set({ set({
slots: repository.list(accountId), slots: refreshLocalSlots(get().slots),
selectedGearOwnerId: classId, selectedGearOwnerId: classId,
selectedInfusionId: infusionsForOwner(classId)[0].id, selectedInfusionId: infusionsForOwner(classId)[0].id,
notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.`, notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.`,
@@ -282,7 +359,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
[save.activeClassId]: { ...save.healers[save.activeClassId], inventory: structuredClone(inventory) }, [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) => { updateSetting: (key, value) => set((state) => {
const settings = { ...state.settings, [key]: value }; const settings = { ...state.settings, [key]: value };
@@ -293,10 +370,10 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
const { activeSlotId, accountId } = get(); const { activeSlotId, accountId } = get();
if (!activeSlotId) return; if (!activeSlotId) return;
repository.touch(activeSlotId); repository.touch(activeSlotId);
set({ slots: repository.list(accountId) }); set((state) => ({ slots: refreshLocalSlots(state.slots) }));
}, },
recordBossVictory: (bossId, difficultySlug) => { recordBossVictory: (bossId, difficultySlug) => {
const { activeSlotId, accountId } = get(); const { activeSlotId } = get();
if (!activeSlotId) return null; if (!activeSlotId) return null;
let awarded: BossRewardAward | null = null; let awarded: BossRewardAward | null = null;
repository.updateLocal(activeSlotId, (save) => { repository.updateLocal(activeSlotId, (save) => {
@@ -311,17 +388,44 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
}; };
}); });
set((state) => ({ set((state) => ({
slots: repository.list(accountId), slots: refreshLocalSlots(state.slots),
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards, recentRewards: awarded ? [...state.recentRewards, awarded].slice(-12) : 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; 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) }));
},
recordRogueTrialsEndlessDefeat: (bossKills) => {
const { activeSlotId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => ({
...save,
stats: {
...save.stats,
highestRogueTrialsEndlessKills: highestEndlessBossKillsAfterDefeat(save.stats.highestRogueTrialsEndlessKills, bossKills),
},
}));
if (!updated) return;
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
clearRecentRewards: () => set({ recentRewards: [] }), clearRecentRewards: () => set({ recentRewards: [] }),
clearNotice: () => set({ notice: "" }), clearNotice: () => set({ notice: "" }),
})); }));
export type FrontendSnapshot = Omit<FrontendState, export type FrontendSnapshot = Omit<FrontendState,
| "restoreSession"
| "signIn" | "signIn"
| "createAccount" | "createAccount"
| "continueOffline" | "continueOffline"
@@ -351,12 +455,15 @@ export type FrontendSnapshot = Omit<FrontendState,
| "updateSetting" | "updateSetting"
| "touchActiveSave" | "touchActiveSave"
| "recordBossVictory" | "recordBossVictory"
| "recordRoguelikeDefeat"
| "recordRogueTrialsEndlessDefeat"
| "clearRecentRewards" | "clearRecentRewards"
| "clearNotice" | "clearNotice"
>; >;
export function getFrontendSnapshot(): FrontendSnapshot { export function getFrontendSnapshot(): FrontendSnapshot {
const { const {
restoreSession: _restoreSession,
signIn: _signIn, signIn: _signIn,
createAccount: _createAccount, createAccount: _createAccount,
continueOffline: _continueOffline, continueOffline: _continueOffline,
@@ -386,6 +493,8 @@ export function getFrontendSnapshot(): FrontendSnapshot {
updateSetting: _updateSetting, updateSetting: _updateSetting,
touchActiveSave: _touchActiveSave, touchActiveSave: _touchActiveSave,
recordBossVictory: _recordBossVictory, recordBossVictory: _recordBossVictory,
recordRoguelikeDefeat: _recordRoguelikeDefeat,
recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat,
clearRecentRewards: _clearRecentRewards, clearRecentRewards: _clearRecentRewards,
clearNotice: _clearNotice, clearNotice: _clearNotice,
...snapshot ...snapshot
+3 -1
View File
@@ -5,7 +5,7 @@ import type { CollectionLog, MaterialStack } from "../game/progression/loot";
export type SaveSlotId = 1 | 2 | 3; export type SaveSlotId = 1 | 2 | 3;
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "settings" | "mode" | "game"; 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 { export interface CollectionDrop {
id: string; id: string;
@@ -41,6 +41,8 @@ export interface HunterStats {
alliesSaved: number; alliesSaved: number;
healingDone: number; healingDone: number;
bossKills: Record<string, number>; bossKills: Record<string, number>;
highestRoguelikeRound: number;
highestRogueTrialsEndlessKills: number;
} }
export interface HealerProgress { export interface HealerProgress {
+88
View File
@@ -0,0 +1,88 @@
import type { BossId } from "./types";
export type AlternateBossKind = Exclude<BossId, "bulldrome">;
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<Exclude<BossId,
| "bulldrome"
| "sandglass-scorpion"
| "cragclaw-crab"
| "mournveil-ghost"
| "crownshard-golem"
| "crystal-bat-matriarch"
>, 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<AlternateBossKind, AlternateBossConfig> = {
"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;
}
+10
View File
@@ -248,4 +248,14 @@ describe("shared boss mechanic pool", () => {
expect(cleansed.motion.poolTelegraphs).toHaveLength(0); expect(cleansed.motion.poolTelegraphs).toHaveLength(0);
expect(cleansed.events[0].message).toContain("cleansing ward"); 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);
});
}); });
+2 -2
View File
@@ -127,8 +127,8 @@ export const SOUL_SIPHON = {
wardDistance: ARENA_RADIUS - 1.05, wardDistance: ARENA_RADIUS - 1.05,
ghostSpeed: 2.6, ghostSpeed: 2.6,
tickInterval: 0.7, tickInterval: 0.7,
tickDamage: 6, tickDamage: 3,
tickRamp: 2, tickRamp: 1,
} as const; } as const;
export const MEMORY_SYMBOLS: Record<MemorySymbolId, { label: string; color: string }> = { export const MEMORY_SYMBOLS: Record<MemorySymbolId, { label: string; color: string }> = {
+35
View File
@@ -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);
});
});
+49
View File
@@ -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;
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { ABILITY_BY_CONTROLLER_BUTTON, ABILITY_CONTROLLER_BINDINGS } from "./controllerBindings";
describe("PlayStation controller ability bindings", () => {
it("keeps prompts aligned with standard gamepad button indices", () => {
expect(ABILITY_BY_CONTROLLER_BUTTON).toEqual({
0: "purify",
1: "shield",
2: "mend",
3: "renew",
4: "radiance",
5: "barrier",
});
expect(Object.values(ABILITY_CONTROLLER_BINDINGS).map(({ glyph }) => glyph)).toEqual([
"□",
"△",
"○",
"✕",
"L1",
"R1",
]);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import type { AbilityId } from "./types";
interface AbilityControllerBinding {
buttonIndex: number;
glyph: string;
}
export const ABILITY_CONTROLLER_BINDINGS: Record<AbilityId, AbilityControllerBinding> = {
mend: { buttonIndex: 2, glyph: DEFAULT_CONTROLLER_GLYPHS.faceLeft },
renew: { buttonIndex: 3, glyph: DEFAULT_CONTROLLER_GLYPHS.faceTop },
shield: { buttonIndex: 1, glyph: DEFAULT_CONTROLLER_GLYPHS.faceRight },
purify: { buttonIndex: 0, glyph: DEFAULT_CONTROLLER_GLYPHS.faceBottom },
radiance: { buttonIndex: 4, glyph: DEFAULT_CONTROLLER_GLYPHS.leftShoulder },
barrier: { buttonIndex: 5, glyph: DEFAULT_CONTROLLER_GLYPHS.rightShoulder },
};
export const ABILITY_BY_CONTROLLER_BUTTON = Object.fromEntries(
Object.entries(ABILITY_CONTROLLER_BINDINGS).map(([abilityId, binding]) => [binding.buttonIndex, abilityId]),
) as Partial<Record<number, AbilityId>>;
+8 -7
View File
@@ -1,12 +1,13 @@
import type { AbilityDefinition, AbilityId, HealerClassDefinition, HealerClassId, InventoryItem } from "./types"; import type { AbilityDefinition, AbilityId, HealerClassDefinition, HealerClassId, InventoryItem } from "./types";
import { ABILITY_CONTROLLER_BINDINGS } from "./controllerBindings";
const bindings: Record<AbilityId, Pick<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">> = { const bindings: Record<AbilityId, Pick<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">> = {
mend: { id: "mend", key: "1", gamepad: "X", targeting: "ally" }, mend: { id: "mend", key: "1", gamepad: ABILITY_CONTROLLER_BINDINGS.mend.glyph, targeting: "ally" },
renew: { id: "renew", key: "2", gamepad: "Y", targeting: "ally" }, renew: { id: "renew", key: "2", gamepad: ABILITY_CONTROLLER_BINDINGS.renew.glyph, targeting: "ally" },
shield: { id: "shield", key: "3", gamepad: "B", targeting: "ally" }, shield: { id: "shield", key: "3", gamepad: ABILITY_CONTROLLER_BINDINGS.shield.glyph, targeting: "ally" },
purify: { id: "purify", key: "4", gamepad: "A", targeting: "ally" }, purify: { id: "purify", key: "4", gamepad: ABILITY_CONTROLLER_BINDINGS.purify.glyph, targeting: "ally" },
radiance: { id: "radiance", key: "5", gamepad: "LB", targeting: "party" }, radiance: { id: "radiance", key: "5", gamepad: ABILITY_CONTROLLER_BINDINGS.radiance.glyph, targeting: "party" },
barrier: { id: "barrier", key: "6", gamepad: "RB", targeting: "party" }, barrier: { id: "barrier", key: "6", gamepad: ABILITY_CONTROLLER_BINDINGS.barrier.glyph, targeting: "party" },
}; };
function ability(id: AbilityId, definition: Omit<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">): AbilityDefinition { function ability(id: AbilityId, definition: Omit<AbilityDefinition, "id" | "key" | "gamepad" | "targeting">): AbilityDefinition {
@@ -73,7 +74,7 @@ const CLASS_INVENTORIES: Record<HealerClassId, InventoryItem[]> = {
priest: [ 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-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-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 }, { 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: [ druid: [
+6 -3
View File
@@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
describe("runtime performance budgets", () => { 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(); const store = useGameStore.getState();
store.configureHealer("priest", "Perf", [], ["emberfox", "sandglass-scorpion"]); store.configureHealer("priest", "Perf", [], bossIds);
store.startEncounter(); store.startEncounter();
useGameStore.setState((state) => ({ useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 1_000_000_000, maxHp: 1_000_000_000 }, 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(maxHazards).toBeLessThanOrEqual(64);
expect(maxDamageEvents).toBeLessThanOrEqual(24); expect(maxDamageEvents).toBeLessThanOrEqual(24);
expect(maxCombatLog).toBeLessThanOrEqual(12); expect(maxCombatLog).toBeLessThanOrEqual(12);
expect(elapsedMs).toBeLessThan(3_500); expect(elapsedMs).toBeLessThan(maxElapsedMs);
}); });
}); });
+14
View File
@@ -3,6 +3,7 @@ import { createEncounterGearModifiers } from "./gearEffects";
import { import {
GEAR_RECIPES, GEAR_RECIPES,
canAffordGearUpgrade, canAffordGearUpgrade,
canUpgradeGearSlot,
createDefaultGearProgress, createDefaultGearProgress,
gearUpgradeCosts, gearUpgradeCosts,
upgradeGearSlot, upgradeGearSlot,
@@ -46,6 +47,19 @@ describe("IWT2-style gear progression", () => {
expect(progress.brann.slots.weapon.level).toBe(0); 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", () => { it("projects rank bonuses without mutating saved progress", () => {
const progress = createDefaultGearProgress(); const progress = createDefaultGearProgress();
progress.priest.slots.weapon.level = 10; progress.priest.slots.weapon.level = 10;
+11
View File
@@ -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); 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[] { export function spendGearCosts(inventory: readonly MaterialStack[], costs: readonly GearUpgradeCost[]): MaterialStack[] {
if (!canAffordGearUpgrade(inventory, costs)) { if (!canAffordGearUpgrade(inventory, costs)) {
const missing = costs.find((cost) => (inventory.find((item) => item.id === cost.itemId)?.quantity ?? 0) < cost.quantity); const missing = costs.find((cost) => (inventory.find((item) => item.id === cost.itemId)?.quantity ?? 0) < cost.quantity);
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { highestEndlessBossKillsAfterDefeat, 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);
});
});
describe("Rogue Trials endless hunter records", () => {
it("keeps the highest boss count from one endless run", () => {
expect(highestEndlessBossKillsAfterDefeat(0, 17)).toBe(17);
expect(highestEndlessBossKillsAfterDefeat(17, 9)).toBe(17);
expect(highestEndlessBossKillsAfterDefeat(17, 23)).toBe(23);
});
it("normalizes invalid and fractional kill counts", () => {
expect(highestEndlessBossKillsAfterDefeat(Number.NaN, Number.NaN)).toBe(0);
expect(highestEndlessBossKillsAfterDefeat(4.9, 8.9)).toBe(8);
});
});
+11
View File
@@ -0,0 +1,11 @@
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);
}
export function highestEndlessBossKillsAfterDefeat(currentRecord: number, bossKills: number): number {
const normalizedRecord = Math.max(0, Math.floor(Number(currentRecord) || 0));
const normalizedKills = Math.max(0, Math.floor(Number(bossKills) || 0));
return Math.max(normalizedRecord, normalizedKills);
}
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { BOSS_GROUPS } from "../bossCatalog"; 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", () => { describe("group drop tables", () => {
it("defines five shared drops for every mechanic group", () => { 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.collectionLog.dropsFound[second.award.drop.id]).toBe(5);
expect(second.award.pet?.id).toBe("bulldrome-pet"); 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();
});
}); });
+25
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { import {
RUN_BUFF_ORDER, RUN_BUFF_ORDER,
RUN_BUFFS, RUN_BUFFS,
@@ -8,6 +9,8 @@ import {
formatRunBuffEffect, formatRunBuffEffect,
increaseRunBuffRank, increaseRunBuffRank,
selectRandomBossPair, selectRandomBossPair,
selectRogueTrialsBosses,
selectUnseenBosses,
selectRunBuffDraft, selectRunBuffDraft,
} from "./roguelike"; } from "./roguelike";
import type { RunBuffRanks } from "./types"; import type { RunBuffRanks } from "./types";
@@ -75,4 +78,26 @@ describe("roguelike progression", () => {
expect(pair).not.toContain("bulldrome"); expect(pair).not.toContain("bulldrome");
expect(pair).not.toContain("broodfang-spider"); 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/);
});
}); });
+33
View File
@@ -204,6 +204,39 @@ export function bossHealthMultiplier(round: number) {
return 1 + Math.max(0, round - 1) * 0.1; 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( export function selectRandomBossPair(
excludedBossIds: readonly BossId[] = [], excludedBossIds: readonly BossId[] = [],
random: () => number = Math.random, random: () => number = Math.random,
+171 -2
View File
@@ -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 { BULL_CHARGE } from "./bossMechanics";
import { distance, pointToSegmentDistance } from "./geometry"; 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 { createClassInventory, HEALER_CLASSES } from "./healers";
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool"; import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
import { ARENA_CENTER, isInsideArena } from "./arena"; import { ARENA_CENTER, isInsideArena } from "./arena";
@@ -581,6 +581,7 @@ describe("Roguelike rounds", () => {
expect(useGameStore.getState().time).toBe(intermissionTime); expect(useGameStore.getState().time).toBe(intermissionTime);
const chosenBuffId = useGameStore.getState().draftBuffIds[0]!; const chosenBuffId = useGameStore.getState().draftBuffIds[0]!;
expect(chosenBuffId).toBeDefined(); expect(chosenBuffId).toBeDefined();
useGameStore.setState({ runBuffInputUnlockAt: 0 });
expect(useGameStore.getState().chooseRunBuff(chosenBuffId)).toBe(true); expect(useGameStore.getState().chooseRunBuff(chosenBuffId)).toBe(true);
const roundTwo = useGameStore.getState(); const roundTwo = useGameStore.getState();
@@ -599,6 +600,34 @@ describe("Roguelike rounds", () => {
expect(useGameStore.getState().round).toBe(1); 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", () => { 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; const maxedRanks = Object.fromEntries(RUN_BUFF_ORDER.map((id) => [id, RUN_BUFFS[id].maxRank])) as RunBuffRanks;
useGameStore.setState({ useGameStore.setState({
@@ -607,8 +636,11 @@ describe("Roguelike rounds", () => {
runModifiers: compileRunModifiers(maxedRanks), runModifiers: compileRunModifiers(maxedRanks),
draftBuffIds: [], draftBuffIds: [],
selectedRunBuffId: null, 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().continueRoguelikeRound()).toBe(true);
expect(useGameStore.getState().phase).toBe("combat"); expect(useGameStore.getState().phase).toBe("combat");
expect(useGameStore.getState().round).toBe(2); expect(useGameStore.getState().round).toBe(2);
@@ -618,6 +650,143 @@ 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("offers endless mode, counts each kill, and refills the dead boss slot", () => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
["ashwing-demon", "riftclaw-demon", "tempestscale-dragon"],
"rogue-trials",
);
useGameStore.setState({ round: 5, phase: "victory" });
expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
const started = useGameStore.getState();
expect(started.phase).toBe("combat");
expect(started.endlessMode).toBe(true);
expect(started.endlessBossKills).toBe(0);
expect(started.additionalBosses).toHaveLength(2);
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 1, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
additionalBosses: state.additionalBosses.map((entry) => ({
...entry,
boss: { ...entry.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
motion: { ...entry.motion, nextMechanicAt: 999 },
})),
}));
for (let step = 0; step < 50 && useGameStore.getState().endlessBossKills === 0; step += 1) {
useGameStore.getState().tick(0.1);
}
const defeated = useGameStore.getState();
expect(defeated.endlessBossKills).toBe(1);
expect(defeated.boss.hp).toBe(0);
const defeatedInstanceId = defeated.bossInstanceId;
useGameStore.getState().tick(0.05);
const replaced = useGameStore.getState();
expect(replaced.phase).toBe("combat");
expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
expect(replaced.bossInstanceId).not.toBe(defeatedInstanceId);
expect(replaced.endlessBossKills).toBe(1);
expect(new Set([replaced.boss.id, ...replaced.additionalBosses.map((entry) => entry.boss.id)])).toHaveLength(3);
});
it("ends endless mode when the party falls without losing its kill count", () => {
useGameStore.setState((state) => ({
round: 5,
phase: "victory",
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
}));
expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
useGameStore.setState((state) => ({
endlessBossKills: 7,
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("defeat");
expect(useGameStore.getState().endlessBossKills).toBe(7);
});
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", () => { describe("shared arena boundary", () => {
beforeEach(() => { beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest")); useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
+155 -16
View File
@@ -24,6 +24,9 @@ import {
runAbilityManaCost, runAbilityManaCost,
selectRunBuffDraft, selectRunBuffDraft,
selectRandomBossPair, selectRandomBossPair,
selectRogueTrialsBosses,
selectUnseenBosses,
ROGUE_TRIALS_TRIO_ROUND,
type CompiledRunModifiers, type CompiledRunModifiers,
} from "./roguelike"; } from "./roguelike";
import { createDefaultGearProgress, type GearProgress } from "./progression/gear"; import { createDefaultGearProgress, type GearProgress } from "./progression/gear";
@@ -65,6 +68,7 @@ export interface AdditionalBossState {
export interface GameState { export interface GameState {
bossId: BossId; bossId: BossId;
bossInstanceId: string;
paused: boolean; paused: boolean;
pauseSelection: "resume" | "exit"; pauseSelection: "resume" | "exit";
healerClassId: HealerClassId; healerClassId: HealerClassId;
@@ -72,9 +76,15 @@ export interface GameState {
phase: GamePhase; phase: GamePhase;
runMode: RunMode; runMode: RunMode;
round: number; round: number;
seenBossIds: BossId[];
endlessMode: boolean;
endlessBossKills: number;
endlessSpawnSequence: number;
endlessChoiceSelection: "continue" | "quit";
runBuffRanks: RunBuffRanks; runBuffRanks: RunBuffRanks;
draftBuffIds: RunBuffId[]; draftBuffIds: RunBuffId[];
selectedRunBuffId: RunBuffId | null; selectedRunBuffId: RunBuffId | null;
runBuffInputUnlockAt: number;
passiveRunBuffId: RunBuffId | null; passiveRunBuffId: RunBuffId | null;
runModifiers: CompiledRunModifiers; runModifiers: CompiledRunModifiers;
healingMultiplier: number; healingMultiplier: number;
@@ -119,6 +129,8 @@ export interface GameState {
setSelectedRunBuff: (buffId: RunBuffId) => void; setSelectedRunBuff: (buffId: RunBuffId) => void;
chooseRunBuff: (buffId: RunBuffId) => boolean; chooseRunBuff: (buffId: RunBuffId) => boolean;
continueRoguelikeRound: () => boolean; continueRoguelikeRound: () => boolean;
startRogueTrialsEndless: () => boolean;
setEndlessChoiceSelection: (selection: "continue" | "quit") => void;
} }
const emptyCooldowns = (): Record<AbilityId, number> => ({ const emptyCooldowns = (): Record<AbilityId, number> => ({
@@ -131,19 +143,20 @@ const emptyCooldowns = (): Record<AbilityId, number> => ({
}); });
export const GLOBAL_COOLDOWN_SECONDS = 0.5; export const GLOBAL_COOLDOWN_SECONDS = 0.5;
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
export const BARRIER_RADIUS = 3; export const BARRIER_RADIUS = 3;
export const BARRIER_DAMAGE_REDUCTION = 0.3; export const BARRIER_DAMAGE_REDUCTION = 0.3;
const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => { const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => {
const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds]; 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"]; return unique.length ? unique : ["bulldrome"];
}; };
function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState { function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState {
const motion = cloneMotion(createBossMotionState(bossId)); 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.formationOffsetX = offset;
motion.position[0] += offset; motion.position[0] += offset;
motion.chargeStart[0] += offset; motion.chargeStart[0] += offset;
@@ -280,6 +293,7 @@ function initialState(
runBuffRanks: RunBuffRanks = {}, runBuffRanks: RunBuffRanks = {},
gearProgress: GearProgress = createDefaultGearProgress(), gearProgress: GearProgress = createDefaultGearProgress(),
requestedDifficultySlug: DifficultySlug = "initiate", requestedDifficultySlug: DifficultySlug = "initiate",
seenBossIds: readonly BossId[] = [],
) { ) {
const difficultySlug = normalizeDifficultySlug(requestedDifficultySlug); const difficultySlug = normalizeDifficultySlug(requestedDifficultySlug);
const difficulty = DIFFICULTY_BY_SLUG[difficultySlug]; const difficulty = DIFFICULTY_BY_SLUG[difficultySlug];
@@ -294,11 +308,12 @@ function initialState(
const gearModifiers = createEncounterGearModifiers(gearProgress, healerClassId); const gearModifiers = createEncounterGearModifiers(gearProgress, healerClassId);
const passiveInfusionId = passiveInfusionUnlocked(gearProgress) ? gearProgress[healerClassId].passiveInfusionId : null; const passiveInfusionId = passiveInfusionUnlocked(gearProgress) ? gearProgress[healerClassId].passiveInfusionId : null;
const runModifiers = compileRunModifiers(runBuffRanks, passiveInfusionId); 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 party = applyGearHealth(freshParty(healerClassId, playerName), gearModifiers);
const maxMana = 100; const maxMana = 100;
return { return {
bossId: primary.boss.id, bossId: primary.boss.id,
bossInstanceId: primary.instanceId,
paused: false, paused: false,
pauseSelection: "resume" as const, pauseSelection: "resume" as const,
healerClassId, healerClassId,
@@ -306,9 +321,15 @@ function initialState(
phase: "briefing" as GamePhase, phase: "briefing" as GamePhase,
runMode, runMode,
round, round,
seenBossIds: [...new Set([...seenBossIds, ...bossIds])],
endlessMode: false,
endlessBossKills: 0,
endlessSpawnSequence: 0,
endlessChoiceSelection: "continue" as const,
runBuffRanks: { ...runBuffRanks }, runBuffRanks: { ...runBuffRanks },
draftBuffIds, draftBuffIds,
selectedRunBuffId: draftBuffIds[0] ?? null, selectedRunBuffId: draftBuffIds[0] ?? null,
runBuffInputUnlockAt: 0,
passiveRunBuffId: passiveInfusionId, passiveRunBuffId: passiveInfusionId,
runModifiers, runModifiers,
healingMultiplier: gearModifiers.aelia.healingPower, healingMultiplier: gearModifiers.aelia.healingPower,
@@ -348,10 +369,10 @@ export const useGameStore = create<GameState>((set, get) => ({
}, },
startEncounter: () => { 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)]; const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)];
set({ set({
...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffRanks, gearProgress, difficultySlug), ...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffRanks, gearProgress, difficultySlug, seenBossIds),
phase: "combat", phase: "combat",
activeTab: "combat", activeTab: "combat",
combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }], combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }],
@@ -360,7 +381,10 @@ export const useGameStore = create<GameState>((set, get) => ({
restart: () => { restart: () => {
const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug } = get(); 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 }), selectMember: (selectedMemberId) => set({ selectedMemberId }),
@@ -380,20 +404,24 @@ export const useGameStore = create<GameState>((set, get) => ({
togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })), togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })),
setPauseSelection: (pauseSelection) => set({ pauseSelection }), setPauseSelection: (pauseSelection) => set({ pauseSelection }),
setSelectedRunBuff: (selectedRunBuffId) => set((state) => setSelectedRunBuff: (selectedRunBuffId) => set((state) =>
state.phase === "intermission" && state.draftBuffIds.includes(selectedRunBuffId) state.phase === "intermission"
&& !isRunBuffInputLocked(state)
&& state.draftBuffIds.includes(selectedRunBuffId)
? { selectedRunBuffId } ? { selectedRunBuffId }
: state : state
), ),
chooseRunBuff: (buffId) => { chooseRunBuff: (buffId) => {
const state = get(); 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 runBuffRanks = increaseRunBuffRank(state.runBuffRanks, buffId);
const round = state.round + 1; const round = state.round + 1;
const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)]; 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; const abilityName = HEALER_CLASSES[state.healerClassId].abilities[RUN_BUFFS[buffId].abilityId].name;
set({ 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", phase: "combat",
activeTab: "combat", activeTab: "combat",
combatLog: [{ combatLog: [{
@@ -407,12 +435,14 @@ export const useGameStore = create<GameState>((set, get) => ({
}, },
continueRoguelikeRound: () => { continueRoguelikeRound: () => {
const state = get(); 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 round = state.round + 1;
const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)]; 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({ 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", phase: "combat",
activeTab: "combat", activeTab: "combat",
combatLog: [{ combatLog: [{
@@ -424,6 +454,46 @@ export const useGameStore = create<GameState>((set, get) => ({
}); });
return true; return true;
}, },
startRogueTrialsEndless: () => {
const state = get();
if (state.runMode !== "rogue-trials"
|| state.round !== ROGUE_TRIALS_TRIO_ROUND
|| state.phase !== "victory"
|| state.endlessMode) return false;
const bossIds = selectRogueTrialsBosses(ROGUE_TRIALS_TRIO_ROUND, []);
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const healthMultiplier = bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier;
const encounterBosses = bossIds.map((bossId, index) => {
const entry = createEncounterBoss(bossId, index, bossIds.length, healthMultiplier);
return { ...entry, instanceId: `endless-${index + 1}-${bossId}` };
});
const primary = encounterBosses[0];
set({
bossId: primary.boss.id,
bossInstanceId: primary.instanceId,
boss: primary.boss,
bossMotion: primary.motion,
additionalBosses: encounterBosses.slice(1),
phase: "combat",
endlessMode: true,
endlessBossKills: 0,
endlessSpawnSequence: encounterBosses.length,
partyCombat: createPartyCombatState(state.party),
partyDamageEvents: [],
partyPositions: freshPartyPositions(bossIds),
playerPosition: [0, 4.5],
activeCast: null,
activeTab: "combat",
combatLog: [{
id: Date.now(),
time: state.time,
message: `${encounterBosses.map((entry) => entry.boss.name).join(", ")} enter the endless trial.`,
tone: "danger",
}],
});
return true;
},
setEndlessChoiceSelection: (endlessChoiceSelection) => set({ endlessChoiceSelection }),
setPlayerPosition: (playerPosition) => set((state) => { setPlayerPosition: (playerPosition) => set((state) => {
playerPosition = clampToArena(playerPosition); playerPosition = clampToArena(playerPosition);
const current = state.playerPosition; const current = state.playerPosition;
@@ -584,6 +654,7 @@ export const useGameStore = create<GameState>((set, get) => ({
// as well doubled short-lived allocations for every simulation step. // as well doubled short-lived allocations for every simulation step.
let party = state.party.map((member) => ({ ...member })); let party = state.party.map((member) => ({ ...member }));
let boss = { ...state.boss }; let boss = { ...state.boss };
let bossInstanceId = state.bossInstanceId;
let bossMotion = { ...state.bossMotion }; let bossMotion = { ...state.bossMotion };
let additionalBosses = state.additionalBosses.map((entry) => ({ let additionalBosses = state.additionalBosses.map((entry) => ({
...entry, ...entry,
@@ -597,6 +668,36 @@ export const useGameStore = create<GameState>((set, get) => ({
let partyCombat = state.partyCombat; let partyCombat = state.partyCombat;
let partyDamageEvents = state.partyDamageEvents; let partyDamageEvents = state.partyDamageEvents;
let barrier = { ...state.barrier }; let barrier = { ...state.barrier };
let endlessBossKills = state.endlessBossKills;
let endlessSpawnSequence = state.endlessSpawnSequence;
if (state.endlessMode) {
const slots: AdditionalBossState[] = [
{ instanceId: bossInstanceId, boss, motion: bossMotion },
...additionalBosses,
];
for (let index = 0; index < slots.length; index += 1) {
if (slots[index].boss.hp > 0) continue;
const activeBossIds = slots
.filter((entry, slotIndex) => slotIndex !== index && entry.boss.hp > 0)
.map((entry) => entry.boss.id);
const replacementId = selectUnseenBosses(1, [slots[index].boss.id, ...activeBossIds])[0];
endlessSpawnSequence += 1;
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const replacement = createEncounterBoss(
replacementId,
index,
slots.length,
bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier,
);
slots[index] = { ...replacement, instanceId: `endless-${endlessSpawnSequence}-${replacementId}` };
combatLog = addLog(combatLog, time, `${replacement.boss.name} replaces the fallen boss.`, "danger");
}
boss = slots[0].boss;
bossInstanceId = slots[0].instanceId;
bossMotion = slots[0].motion;
additionalBosses = slots.slice(1);
}
if (activeCast && activeCast.completesAt <= time) { if (activeCast && activeCast.completesAt <= time) {
const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId); const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId);
@@ -666,7 +767,7 @@ export const useGameStore = create<GameState>((set, get) => ({
}); });
const encounterBosses: AdditionalBossState[] = [ const encounterBosses: AdditionalBossState[] = [
{ instanceId: `boss-0-${boss.id}`, boss, motion: bossMotion }, { instanceId: bossInstanceId, boss, motion: bossMotion },
...additionalBosses, ...additionalBosses,
]; ];
for (let index = 0; index < encounterBosses.length; index += 1) { for (let index = 0; index < encounterBosses.length; index += 1) {
@@ -716,13 +817,35 @@ export const useGameStore = create<GameState>((set, get) => ({
if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount); if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount);
} }
boss = encounterBosses[0].boss; boss = encounterBosses[0].boss;
bossInstanceId = encounterBosses[0].instanceId;
bossMotion = encounterBosses[0].motion; bossMotion = encounterBosses[0].motion;
additionalBosses = encounterBosses.slice(1); additionalBosses = encounterBosses.slice(1);
const tank = party.find((member) => member.id === "brann")!; const tank = party.find((member) => member.id === "brann")!;
const healer = party.find((member) => member.id === "aelia")!; const healer = party.find((member) => member.id === "aelia")!;
let phase: GamePhase = state.phase; let phase: GamePhase = state.phase;
if (encounterBosses.every((entry) => entry.boss.hp <= 0)) { let runBuffInputUnlockAt = state.runBuffInputUnlockAt;
phase = state.runMode === "roguelike" ? "intermission" : "victory"; let newlyDefeatedBossCount = 0;
if (state.endlessMode) {
for (let index = 0; index < encounterBosses.length; index += 1) {
const current = encounterBosses[index];
const previous = index === 0
? { instanceId: state.bossInstanceId, boss: state.boss }
: state.additionalBosses[index - 1];
if (current.boss.hp > 0 || previous?.instanceId !== current.instanceId || previous.boss.hp <= 0) continue;
newlyDefeatedBossCount += 1;
combatLog = addLog(combatLog, time, `${current.boss.name} falls. Endless kill ${endlessBossKills + newlyDefeatedBossCount}.`, "good");
}
}
endlessBossKills += newlyDefeatedBossCount;
if (state.endlessMode && (tank.hp <= 0 || healer.hp <= 0)) {
phase = "defeat";
combatLog = addLog(combatLog, time, `${endlessBossKills} endless bosses defeated before the formation fell.`, "danger");
} else if (state.endlessMode) {
phase = "combat";
} else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
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"); combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good");
} else if (tank.hp <= 0 || healer.hp <= 0) { } else if (tank.hp <= 0 || healer.hp <= 0) {
phase = "defeat"; phase = "defeat";
@@ -732,6 +855,8 @@ export const useGameStore = create<GameState>((set, get) => ({
set({ set({
time, time,
party, party,
bossId: boss.id,
bossInstanceId,
boss, boss,
additionalBosses, additionalBosses,
partyCombat, partyCombat,
@@ -739,6 +864,9 @@ export const useGameStore = create<GameState>((set, get) => ({
partyPositions, partyPositions,
bossMotion, bossMotion,
phase, phase,
endlessBossKills,
endlessSpawnSequence,
runBuffInputUnlockAt,
mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)), mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)),
activeCast, activeCast,
combatLog, combatLog,
@@ -765,6 +893,8 @@ export type GameSnapshot = Omit<GameState,
| "setSelectedRunBuff" | "setSelectedRunBuff"
| "chooseRunBuff" | "chooseRunBuff"
| "continueRoguelikeRound" | "continueRoguelikeRound"
| "startRogueTrialsEndless"
| "setEndlessChoiceSelection"
>; >;
export function getGameSnapshot(): GameSnapshot { export function getGameSnapshot(): GameSnapshot {
@@ -785,6 +915,8 @@ export function getGameSnapshot(): GameSnapshot {
setSelectedRunBuff: _setSelectedRunBuff, setSelectedRunBuff: _setSelectedRunBuff,
chooseRunBuff: _chooseRunBuff, chooseRunBuff: _chooseRunBuff,
continueRoguelikeRound: _continueRoguelikeRound, continueRoguelikeRound: _continueRoguelikeRound,
startRogueTrialsEndless: _startRogueTrialsEndless,
setEndlessChoiceSelection: _setEndlessChoiceSelection,
...snapshot ...snapshot
} = useGameStore.getState(); } = useGameStore.getState();
return snapshot; return snapshot;
@@ -794,6 +926,13 @@ export function abilityRemaining(abilityId: AbilityId, time: number, cooldowns:
return Math.max(0, cooldowns[abilityId] - time); return Math.max(0, cooldowns[abilityId] - time);
} }
export function isRunBuffInputLocked(
state: Pick<GameState, "phase" | "runBuffInputUnlockAt">,
now = Date.now(),
) {
return state.phase === "intermission" && now < state.runBuffInputUnlockAt;
}
export { upcomingMechanic }; export { upcomingMechanic };
export function upcomingEncounterMechanic(state: Pick<GameState, "boss" | "bossMotion" | "additionalBosses" | "time">) { export function upcomingEncounterMechanic(state: Pick<GameState, "boss" | "bossMotion" | "additionalBosses" | "time">) {
+1 -1
View File
@@ -61,7 +61,7 @@ export type BossMechanicId =
| "soul-siphon"; | "soul-siphon";
export type BossAnimationCue = "idle" | "move" | "attack" | "special"; export type BossAnimationCue = "idle" | "move" | "attack" | "special";
export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat"; export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat";
export type RunMode = "encounter" | "roguelike"; export type RunMode = "encounter" | "roguelike" | "rogue-trials";
export type RunBuffId = export type RunBuffId =
| "mend-echo" | "mend-echo"
| "mend-efficiency" | "mend-efficiency"
+26 -12
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { subscribeControllerToken } from "../input/controller"; import { subscribeControllerToken } from "../input/controller";
import { ABILITY_ORDER } from "./data"; import { ABILITY_ORDER } from "./data";
import { useGameStore } from "./store"; import { isRunBuffInputLocked, useGameStore } from "./store";
import type { AbilityId } from "./types"; import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings";
function cycleRunBuff(direction: 1 | -1) { function cycleRunBuff(direction: 1 | -1) {
const store = useGameStore.getState(); const store = useGameStore.getState();
@@ -12,15 +12,6 @@ function cycleRunBuff(direction: 1 | -1) {
store.setSelectedRunBuff(store.draftBuffIds[nextIndex]); store.setSelectedRunBuff(store.draftBuffIds[nextIndex]);
} }
const gamepadAbilityMap: Record<number, AbilityId> = {
0: "purify",
1: "shield",
2: "mend",
3: "renew",
4: "radiance",
5: "barrier",
};
export function useActionBindings(enabled = true, onExit?: () => void) { export function useActionBindings(enabled = true, onExit?: () => void) {
const exitRef = useRef(onExit); const exitRef = useRef(onExit);
exitRef.current = onExit; exitRef.current = onExit;
@@ -43,6 +34,7 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
} }
if (store.phase === "intermission") { if (store.phase === "intermission") {
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault(); if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (isRunBuffInputLocked(store)) return;
if (key === "arrowleft" || key === "arrowup") cycleRunBuff(-1); if (key === "arrowleft" || key === "arrowup") cycleRunBuff(-1);
if (key === "arrowright" || key === "arrowdown") cycleRunBuff(1); if (key === "arrowright" || key === "arrowdown") cycleRunBuff(1);
if (key === "enter") { if (key === "enter") {
@@ -52,6 +44,17 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
if (key === "escape") exitRef.current?.(); if (key === "escape") exitRef.current?.();
return; return;
} }
if (store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) {
if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault();
if (key === "arrowleft" || key === "arrowup") store.setEndlessChoiceSelection("continue");
if (key === "arrowright" || key === "arrowdown") store.setEndlessChoiceSelection("quit");
if (key === "enter") {
if (store.endlessChoiceSelection === "continue") store.startRogueTrialsEndless();
else exitRef.current?.();
}
if (key === "escape") exitRef.current?.();
return;
}
const numberIndex = Number(event.key) - 1; const numberIndex = Number(event.key) - 1;
if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) { if (numberIndex >= 0 && numberIndex < ABILITY_ORDER.length) {
store.castAbility(ABILITY_ORDER[numberIndex]); store.castAbility(ABILITY_ORDER[numberIndex]);
@@ -99,6 +102,7 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
return; return;
} }
if (store.phase === "intermission") { if (store.phase === "intermission") {
if (isRunBuffInputLocked(store)) return;
if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1); if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1);
if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) cycleRunBuff(1); if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) cycleRunBuff(1);
if (!repeat && token === "Button0") { if (!repeat && token === "Button0") {
@@ -108,9 +112,19 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
if (!repeat && token === "Button1") exitRef.current?.(); if (!repeat && token === "Button1") exitRef.current?.();
return; return;
} }
if (store.phase === "victory" && store.runMode === "rogue-trials" && store.round === 5 && !store.endlessMode) {
if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) store.setEndlessChoiceSelection("continue");
if (["Button13", "Button15", "Axis0+", "Axis1+"].includes(token)) store.setEndlessChoiceSelection("quit");
if (!repeat && token === "Button0") {
if (store.endlessChoiceSelection === "continue") store.startRogueTrialsEndless();
else exitRef.current?.();
}
if (!repeat && token === "Button1") exitRef.current?.();
return;
}
if (repeat) return; if (repeat) return;
if (token.startsWith("Button")) { if (token.startsWith("Button")) {
const ability = gamepadAbilityMap[Number(token.slice("Button".length))]; const ability = ABILITY_BY_CONTROLLER_BUTTON[Number(token.slice("Button".length))];
if (ability && store.phase === "combat") store.castAbility(ability); if (ability && store.phase === "combat") store.castAbility(ability);
} }
if (token === "Button12") store.cycleMember(-1); if (token === "Button12") store.cycleMember(-1);
+7 -7
View File
@@ -10,18 +10,18 @@ describe("controller movement normalization", () => {
beforeEach(() => resetControllerState()); beforeEach(() => resetControllerState());
it("ignores dead-zone noise and quantizes analog jitter", () => { 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) => { 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); expect(updates).toHaveLength(0);
setExternalControllerMovement({ x: 0.5001, y: -0.5001 }); setExternalControllerMovement({ moveX: 0.5001, moveY: -0.5001, lookX: 0.2501, lookY: -0.2501 });
setExternalControllerMovement({ x: 0.5002, y: -0.5002 }); setExternalControllerMovement({ moveX: 0.5002, moveY: -0.5002, lookX: 0.2502, lookY: -0.2502 });
expect(updates).toEqual([{ x: 0.5, y: -0.5 }]); expect(updates).toEqual([{ moveX: 0.5, moveY: -0.5, lookX: 0.25, lookY: -0.25 }]);
expect(getControllerMovement()).toEqual({ x: 0.5, y: -0.5 }); expect(getControllerMovement()).toEqual({ moveX: 0.5, moveY: -0.5, lookX: 0.25, lookY: -0.25 });
unsubscribe(); unsubscribe();
}); });
+29 -11
View File
@@ -6,8 +6,10 @@ export interface ControllerTokenEvent {
} }
export interface ControllerMovement { export interface ControllerMovement {
x: number; moveX: number;
y: number; moveY: number;
lookX: number;
lookY: number;
} }
type TokenListener = (event: ControllerTokenEvent) => void; type TokenListener = (event: ControllerTokenEvent) => void;
@@ -30,7 +32,7 @@ const repeatAt = new Map<string, number>();
const lastNativeTokenAt = new Map<string, number>(); const lastNativeTokenAt = new Map<string, number>();
let previousTokens = new Set<string>(); let previousTokens = new Set<string>();
let currentTokens = new Set<string>(); let currentTokens = new Set<string>();
let movement: ControllerMovement = { x: 0, y: 0 }; let movement: ControllerMovement = { moveX: 0, moveY: 0, lookX: 0, lookY: 0 };
let stopService: (() => void) | null = null; let stopService: (() => void) | null = null;
let dispatchDepth = 0; let dispatchDepth = 0;
@@ -97,14 +99,25 @@ export function getControllerMovement(): Readonly<ControllerMovement> {
} }
export function setExternalControllerMovement(next: ControllerMovement) { 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) { function normalizedAxis(value: number) {
const x = Math.abs(nextX) >= 0.12 ? Math.round(nextX * MOVEMENT_AXIS_STEPS) / MOVEMENT_AXIS_STEPS : 0; return Math.abs(value) >= 0.12 ? Math.round(value * 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 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); for (const listener of movementListeners) listener(movement);
} }
@@ -113,7 +126,7 @@ export function resetControllerState() {
currentTokens.clear(); currentTokens.clear();
repeatAt.clear(); repeatAt.clear();
lastNativeTokenAt.clear(); lastNativeTokenAt.clear();
setExternalControllerAxes(0, 0); setExternalControllerAxes(0, 0, 0, 0);
} }
export function startControllerInput() { export function startControllerInput() {
@@ -153,7 +166,12 @@ export function startControllerInput() {
hadConnectedGamepad = false; hadConnectedGamepad = false;
} else { } else {
hadConnectedGamepad = true; 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); tokensFor(gamepad, currentTokens);
for (const token of currentTokens) { for (const token of currentTokens) {
const pressed = !previousTokens.has(token); const pressed = !previousTokens.has(token);
+12
View File
@@ -0,0 +1,12 @@
export const DEFAULT_CONTROLLER_GLYPHS = {
confirm: "✕",
back: "○",
faceBottom: "✕",
faceRight: "○",
faceLeft: "□",
faceTop: "△",
leftShoulder: "L1",
rightShoulder: "R1",
select: "SELECT",
start: "START",
} as const;
+20 -7
View File
@@ -9,6 +9,7 @@ import type { BossId } from "../game/types";
import type { DifficultySlug } from "../game/progression/loot"; import type { DifficultySlug } from "../game/progression/loot";
import { useForcedThorDisplays } from "./useThorDualScreen"; import { useForcedThorDisplays } from "./useThorDualScreen";
import { createRateLimitedPublisher } from "./rateLimitedPublisher"; import { createRateLimitedPublisher } from "./rateLimitedPublisher";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33; const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33;
@@ -45,8 +46,8 @@ function CompanionStandby({ screen, hunterName, notice }: {
</main> </main>
<footer> <footer>
<span><b></b> Navigate</span> <span><b></b> Navigate</span>
<span><b>A</b> Select</span> <span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>
<span><b>B</b> Back</span> <span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>
</footer> </footer>
</section> </section>
); );
@@ -77,11 +78,11 @@ export function BottomDisplayApp() {
const sentControllerIds = new Set<string>(); const sentControllerIds = new Set<string>();
let controllerSequence = 0; let controllerSequence = 0;
let receivingControllerEcho = false; let receivingControllerEcho = false;
let latestMovement = { x: 0, y: 0 }; let latestMovement = { moveX: 0, moveY: 0, lookX: 0, lookY: 0 };
const movementPublisher = createRateLimitedPublisher(() => { const movementPublisher = createRateLimitedPublisher(() => {
channel.postMessage({ channel.postMessage({
type: "controller-motion", type: "controller-motion",
movement: { x: latestMovement.x, y: latestMovement.y }, movement: { ...latestMovement },
} satisfies DualScreenMessage); } satisfies DualScreenMessage);
}, CONTROLLER_MOTION_SYNC_INTERVAL_MS); }, CONTROLLER_MOTION_SYNC_INTERVAL_MS);
const announceReady = () => channel.postMessage({ type: "companion-ready" } satisfies DualScreenMessage); const announceReady = () => channel.postMessage({ type: "companion-ready" } satisfies DualScreenMessage);
@@ -94,6 +95,7 @@ export function BottomDisplayApp() {
if (!isControllerDispatchActive()) channel.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage); if (!isControllerDispatchActive()) channel.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage);
}; };
useFrontendStore.setState({ useFrontendStore.setState({
restoreSession: () => Promise.resolve(false),
signIn: (username, password) => { signIn: (username, password) => {
postFrontend({ name: "signIn", username, password }); postFrontend({ name: "signIn", username, password });
return Promise.resolve(false); return Promise.resolve(false);
@@ -113,8 +115,14 @@ export function BottomDisplayApp() {
playSlot: (slotId) => postFrontend({ name: "playSlot", slotId }), playSlot: (slotId) => postFrontend({ name: "playSlot", slotId }),
deleteSlot: (slotId) => postFrontend({ name: "deleteSlot", slotId }), deleteSlot: (slotId) => postFrontend({ name: "deleteSlot", slotId }),
copySlot: (sourceId, targetId) => postFrontend({ name: "copySlot", sourceId, targetId }), copySlot: (sourceId, targetId) => postFrontend({ name: "copySlot", sourceId, targetId }),
uploadSlot: (slotId) => postFrontend({ name: "uploadSlot", slotId }), uploadSlot: (slotId) => {
downloadSlot: (slotId) => postFrontend({ name: "downloadSlot", slotId }), postFrontend({ name: "uploadSlot", slotId });
return Promise.resolve();
},
downloadSlot: (slotId) => {
postFrontend({ name: "downloadSlot", slotId });
return Promise.resolve();
},
selectMode: (mode) => postFrontend({ name: "selectMode", mode }), selectMode: (mode) => postFrontend({ name: "selectMode", mode }),
selectBoss: (bossId) => postFrontend({ name: "selectBoss", bossId }), selectBoss: (bossId) => postFrontend({ name: "selectBoss", bossId }),
selectDifficulty: (difficultySlug) => postFrontend({ name: "selectDifficulty", difficultySlug }), selectDifficulty: (difficultySlug) => postFrontend({ name: "selectDifficulty", difficultySlug }),
@@ -161,6 +169,11 @@ export function BottomDisplayApp() {
postCommand({ name: "continueRoguelikeRound" }); postCommand({ name: "continueRoguelikeRound" });
return false; return false;
}, },
startRogueTrialsEndless: () => {
postCommand({ name: "startRogueTrialsEndless" });
return false;
},
setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }),
}); });
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => { channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "authoritative-ready") { if (event.data.type === "authoritative-ready") {
@@ -214,7 +227,7 @@ export function BottomDisplayApp() {
return ( return (
<main className="bottom-display-root"> <main className="bottom-display-root">
{surface.screen === "game" {surface.screen === "game"
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen /></Suspense> ? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen onExit={() => postFrontendCommand({ name: "exitGame" })} /></Suspense>
: surface.notice === "Linking upper display…" : surface.notice === "Linking upper display…"
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} /> ? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
: <FrontEnd onLaunch={launchGame} />} : <FrontEnd onLaunch={launchGame} />}
+12 -3
View File
@@ -6,10 +6,14 @@ import { useFrontendStore } from "../frontend/store";
function snapshot(): BottomGameSnapshot { function snapshot(): BottomGameSnapshot {
return { return {
bossId: "bulldrome", bossId: "bulldrome",
bossInstanceId: "boss-0-bulldrome",
paused: false, paused: false,
healerClassId: "priest", healerClassId: "priest",
phase: "combat", phase: "combat",
round: 1, round: 1,
endlessMode: false,
endlessBossKills: 0,
endlessChoiceSelection: "continue",
runModifiers: { runModifiers: {
mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1, mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1,
renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1, renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1,
@@ -67,21 +71,26 @@ describe("dual-screen game snapshots", () => {
it("routes maxed-run continuation and passive filter commands", () => { it("routes maxed-run continuation and passive filter commands", () => {
const originalContinue = useGameStore.getState().continueRoguelikeRound; const originalContinue = useGameStore.getState().continueRoguelikeRound;
const originalStartEndless = useGameStore.getState().startRogueTrialsEndless;
const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility; const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility;
const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion; const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion;
const calls: string[] = []; const calls: string[] = [];
useGameStore.setState({ continueRoguelikeRound: () => { calls.push("continue"); return true; } }); useGameStore.setState({
continueRoguelikeRound: () => { calls.push("continue"); return true; },
startRogueTrialsEndless: () => { calls.push("endless"); return true; },
});
useFrontendStore.setState({ useFrontendStore.setState({
selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); }, selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); },
selectPassiveInfusion: (passiveId) => { calls.push(`passive:${passiveId}`); }, selectPassiveInfusion: (passiveId) => { calls.push(`passive:${passiveId}`); },
}); });
executeGameCommand({ name: "continueRoguelikeRound" }); executeGameCommand({ name: "continueRoguelikeRound" });
executeGameCommand({ name: "startRogueTrialsEndless" });
executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "shield" }); executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "shield" });
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" }); executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
expect(calls).toEqual(["continue", "ability:shield", "passive:shield-guard"]); expect(calls).toEqual(["continue", "endless", "ability:shield", "passive:shield-guard"]);
useGameStore.setState({ continueRoguelikeRound: originalContinue }); useGameStore.setState({ continueRoguelikeRound: originalContinue, startRogueTrialsEndless: originalStartEndless });
useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, selectPassiveInfusion: originalSelectPassive }); useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, selectPassiveInfusion: originalSelectPassive });
}); });
}); });
+17 -2
View File
@@ -23,7 +23,9 @@ export type GameCommand =
| { name: "setPauseSelection"; selection: "resume" | "exit" } | { name: "setPauseSelection"; selection: "resume" | "exit" }
| { name: "setSelectedRunBuff"; buffId: RunBuffId } | { name: "setSelectedRunBuff"; buffId: RunBuffId }
| { name: "chooseRunBuff"; buffId: RunBuffId } | { name: "chooseRunBuff"; buffId: RunBuffId }
| { name: "continueRoguelikeRound" }; | { name: "continueRoguelikeRound" }
| { name: "startRogueTrialsEndless" }
| { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" };
export type FrontendCommand = export type FrontendCommand =
| { name: "signIn"; username: string; password: string } | { name: "signIn"; username: string; password: string }
@@ -52,9 +54,11 @@ export type FrontendCommand =
| { name: "equipPassiveInfusion"; passiveId: RunBuffId } | { name: "equipPassiveInfusion"; passiveId: RunBuffId }
| { name: "selectHealerClass"; classId: HealerClassId } | { name: "selectHealerClass"; classId: HealerClassId }
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] } | { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
| { name: "exitGame" }
| { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug }; | { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug };
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game"; export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
export const DUAL_SCREEN_EXIT_EVENT = "iwt:dual-screen-exit-game";
export type DualScreenMessage = export type DualScreenMessage =
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> } | { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> }
@@ -86,6 +90,8 @@ export function executeGameCommand(command: GameCommand) {
case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break; case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break;
case "chooseRunBuff": game.chooseRunBuff(command.buffId); break; case "chooseRunBuff": game.chooseRunBuff(command.buffId); break;
case "continueRoguelikeRound": game.continueRoguelikeRound(); break; case "continueRoguelikeRound": game.continueRoguelikeRound(); break;
case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break;
case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break;
} }
} }
@@ -118,6 +124,7 @@ export function executeFrontendCommand(command: FrontendCommand) {
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break; case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
case "selectHealerClass": frontend.selectHealerClass(command.classId); break; case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
case "updateSetting": frontend.updateSetting(command.key, command.value); break; case "updateSetting": frontend.updateSetting(command.key, command.value); break;
case "exitGame": window.dispatchEvent(new Event(DUAL_SCREEN_EXIT_EVENT)); break;
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug } })); break; case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug } })); break;
} }
} }
@@ -132,10 +139,14 @@ export function receiveAuthoritativeMessage(message: DualScreenMessage) {
/** State the lower display actually renders. Renderer-only and progression data stay local to the authoritative screen. */ /** State the lower display actually renders. Renderer-only and progression data stay local to the authoritative screen. */
export type BottomGameSnapshot = Pick<GameState, export type BottomGameSnapshot = Pick<GameState,
| "bossId" | "bossId"
| "bossInstanceId"
| "paused" | "paused"
| "healerClassId" | "healerClassId"
| "phase" | "phase"
| "round" | "round"
| "endlessMode"
| "endlessBossKills"
| "endlessChoiceSelection"
| "runModifiers" | "runModifiers"
| "time" | "time"
| "party" | "party"
@@ -158,7 +169,7 @@ export type BottomGameSnapshot = Pick<GameState,
>; >;
const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [ const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [
"bossId", "paused", "healerClassId", "phase", "round", "runModifiers", "time", "party", "boss", "additionalBosses", "bossId", "bossInstanceId", "paused", "healerClassId", "phase", "round", "endlessMode", "endlessBossKills", "endlessChoiceSelection", "runModifiers", "time", "party", "boss", "additionalBosses",
"partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns", "partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns",
"globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier", "globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier",
]; ];
@@ -184,10 +195,14 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot {
const state = useGameStore.getState(); const state = useGameStore.getState();
return { return {
bossId: state.bossId, bossId: state.bossId,
bossInstanceId: state.bossInstanceId,
paused: state.paused, paused: state.paused,
healerClassId: state.healerClassId, healerClassId: state.healerClassId,
phase: state.phase, phase: state.phase,
round: state.round, round: state.round,
endlessMode: state.endlessMode,
endlessBossKills: state.endlessBossKills,
endlessChoiceSelection: state.endlessChoiceSelection,
runModifiers: state.runModifiers, runModifiers: state.runModifiers,
time: state.time, time: state.time,
party: state.party, party: state.party,
+1 -1
View File
@@ -92,7 +92,7 @@ export function useAuthoritativeDualScreenSync() {
companionReady = false; companionReady = false;
lastPublishedGame = undefined; lastPublishedGame = undefined;
gamePublisher.cancel(); gamePublisher.cancel();
setExternalControllerMovement({ x: 0, y: 0 }); setExternalControllerMovement({ moveX: 0, moveY: 0, lookX: 0, lookY: 0 });
} else if (event.data.type === "controller-token") { } else if (event.data.type === "controller-token") {
receivingRelayedToken = true; receivingRelayedToken = true;
emitControllerToken(event.data.event); emitControllerToken(event.data.event);
+134 -36
View File
@@ -52,13 +52,13 @@ button:focus-visible {
outline-offset: 2px; outline-offset: 2px;
} }
.prototype-shell { .app-shell {
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
padding: 22px 24px 70px; padding: 22px 24px 70px;
} }
.prototype-header { .app-header {
width: min(var(--thor-main-css-width), 100%); width: min(var(--thor-main-css-width), 100%);
margin: 0 auto 16px; margin: 0 auto 16px;
display: flex; display: flex;
@@ -69,31 +69,31 @@ button:focus-visible {
letter-spacing: 0.13em; letter-spacing: 0.13em;
} }
.prototype-header div { .app-header div {
display: grid; display: grid;
} }
.prototype-header span, .app-header span,
.prototype-header p { .app-header p {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
} }
.prototype-header strong { .app-header strong {
color: var(--ink); color: var(--ink);
font-family: "Cinzel", Georgia, serif; font-family: "Cinzel", Georgia, serif;
font-size: 19px; font-size: 19px;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.prototype-header p { .app-header p {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.prototype-header p i { .app-header p i {
width: 3px; width: 3px;
height: 3px; height: 3px;
border-radius: 50%; border-radius: 50%;
@@ -366,10 +366,13 @@ button:focus-visible {
transform: translateX(-50%); transform: translateX(-50%);
} }
.boss-bar-wrap.is-dual { top: 2.2%; width: 39%; display: grid; gap: 3px; } .boss-bar-wrap.is-multi { top: 2.2%; width: 39%; display: grid; gap: 3px; }
.boss-bar-wrap.is-dual .boss-name strong { font-size: 10px; } .boss-bar-wrap.is-multi .boss-name strong { font-size: 10px; }
.boss-bar-wrap.is-dual .boss-name { font-size: 6px; } .boss-bar-wrap.is-multi .boss-name { font-size: 6px; }
.boss-bar-wrap.is-dual .boss-bar { height: 6px; margin-top: 2px; padding: 1px; } .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 { .boss-name {
display: grid; display: grid;
@@ -1072,6 +1075,7 @@ button:focus-visible {
.end-actions { display: flex; gap: 9px; margin-top: 18px; } .end-actions { display: flex; gap: 9px; margin-top: 18px; }
.end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; } .end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; }
.end-actions button.secondary { color: #9eafa8; background: transparent; border-color: #43564f; } .end-actions button.secondary { color: #9eafa8; background: transparent; border-color: #43564f; }
.end-actions button.is-controller-focused { outline: 2px solid #fff1b6; outline-offset: 2px; }
.buff-draft { .buff-draft {
height: 100%; height: 100%;
@@ -1134,6 +1138,8 @@ button:focus-visible {
text-align: left; text-align: left;
} }
.buff-choice-grid button.is-controller-focused { outline: 2px solid var(--gold-strong); outline-offset: 2px; transform: translateY(-2px); } .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 > 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 > 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; } .buff-choice-grid button small { color: var(--buff-accent); font-size: 6px; letter-spacing: 0.1em; text-transform: uppercase; }
@@ -1278,9 +1284,9 @@ button:focus-visible {
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.prototype-shell { padding: 10px 7px 45px; } .app-shell { padding: 10px 7px 45px; }
.prototype-header { padding: 0 4px; } .app-header { padding: 0 4px; }
.prototype-header p { display: none; } .app-header p { display: none; }
.screen-label span { font-size: 8px; } .screen-label span { font-size: 8px; }
.screen-label small { display: none; } .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); } .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 +1330,7 @@ button:focus-visible {
.native-platform, .native-platform,
.native-platform body, .native-platform body,
.native-platform #root, .native-platform #root,
.native-platform .prototype-shell, .native-platform .app-shell,
.native-platform .device-frame { .native-platform .device-frame {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -1332,11 +1338,11 @@ button:focus-visible {
overflow: hidden; overflow: hidden;
} }
.native-platform .prototype-shell { .native-platform .app-shell {
padding: 0; padding: 0;
} }
.native-platform .prototype-header, .native-platform .app-header,
.native-platform .screen-label, .native-platform .screen-label,
.native-platform .hinge { .native-platform .hinge {
display: none; display: none;
@@ -1750,10 +1756,7 @@ button:focus-visible {
.home-header > span { margin-left: auto; color: #8fa39b; font-size: 10px; } .home-header > span { margin-left: auto; color: #8fa39b; font-size: 10px; }
.home-header > span b { color: #dce9e4; } .home-header > span b { color: #dce9e4; }
.home-header > i { color: #6ecaa7; font-size: 8px; font-style: normal; font-weight: 700; letter-spacing: 0.08em; } .home-header > i { color: #6ecaa7; font-size: 8px; font-style: normal; font-weight: 700; letter-spacing: 0.08em; }
.home-title { padding: 17px 2px 12px; } .mode-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, 78px); gap: 10px; margin-top: 18px; }
.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-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 { 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::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; } .mode-card.is-wide { grid-row: 1 / 3; }
@@ -1811,6 +1814,10 @@ button:focus-visible {
/* Profile */ /* Profile */
.profile-surface { padding: 0 30px; } .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 { display: flex; align-items: end; justify-content: space-between; padding: 16px 2px 10px; }
.collection-heading > span { display: grid; } .collection-heading > span { display: grid; }
.collection-heading small { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; } .collection-heading small { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; }
@@ -1832,18 +1839,70 @@ button:focus-visible {
.collection-note > span { display: flex; align-items: baseline; gap: 8px; } .collection-note > span { display: flex; align-items: baseline; gap: 8px; }
.collection-note strong { font-size: 9px; } .collection-note strong { font-size: 9px; }
.collection-note small { color: #758a82; font-size: 8px; } .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: 51px; 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 { padding: 0 5.5% 18px; }
.profile-context .context-header { margin: 0 -5.8%; } .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 { display: grid; grid-template-columns: repeat(6, minmax(0, 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 { min-width: 0; display: grid; padding: 8px 6px; border-right: 1px solid var(--line); }
.profile-stats span:nth-child(even) { border-right: 0; } .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 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; } .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: 13px; } .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 > 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.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 button > span { display: grid; }
.boss-log strong { font-size: clamp(10px, 2.15cqw, 13px); } .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; } .boss-log small { color: #687d75; font-size: clamp(7px, 1.45cqw, 9px); text-transform: uppercase; }
@@ -1881,10 +1940,10 @@ button:focus-visible {
.pad-diagram b, .pad-diagram b,
.face-diagram b { color: #40534c; } .face-diagram b { color: #40534c; }
.face-diagram i { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid currentColor; border-radius: 50%; font-size: 10px; font-style: normal; } .face-diagram i { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid currentColor; border-radius: 50%; font-size: 10px; font-style: normal; }
.face-diagram .a { color: #67c394; } .face-diagram .triangle { color: #70c18b; }
.face-diagram .b { color: #d66b61; } .face-diagram .circle { color: #dc6f78; }
.face-diagram .x { color: #5db1d0; } .face-diagram .cross { color: #78a9dd; }
.face-diagram .y { color: #d6ba67; } .face-diagram .square { color: #cf8fc5; }
.mapping-list { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 15px; } .mapping-list { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 15px; }
.mapping-list span { padding: 8px; border: 1px solid var(--line); color: #83978f; font-size: clamp(8px, 1.7cqw, 10px); } .mapping-list span { padding: 8px; border: 1px solid var(--line); color: #83978f; font-size: clamp(8px, 1.7cqw, 10px); }
.mapping-list b { color: #d6e3de; } .mapping-list b { color: #d6e3de; }
@@ -2005,6 +2064,9 @@ button:focus-visible {
.front-screen-header { height: 44px; grid-template-columns: 125px 1fr auto; gap: 7px; } .front-screen-header { height: 44px; grid-template-columns: 125px 1fr auto; gap: 7px; }
.front-screen-header h1 { font-size: 11px; } .front-screen-header h1 { font-size: 11px; }
.save-surface, .home-surface, .profile-surface, .settings-surface, .mode-surface { padding-right: 12px; padding-left: 12px; } .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-grid { height: calc(100% - 75px); gap: 5px; }
.save-slot { height: 84%; padding: 7px 5px; } .save-slot { height: 84%; padding: 7px 5px; }
.slot-portrait { width: 36px; height: 36px; margin-top: 7px; font-size: 14px; } .slot-portrait { width: 36px; height: 36px; margin-top: 7px; font-size: 14px; }
@@ -2019,9 +2081,7 @@ button:focus-visible {
.save-footer .controller-legend { display: none; } .save-footer .controller-legend { display: none; }
.home-header { height: 35px; } .home-header { height: 35px; }
.home-header > span, .home-header > i { font-size: 5px; } .home-header > span, .home-header > i { font-size: 5px; }
.home-title { padding: 6px 0; } .mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; margin-top: 6px; }
.home-title h1 { font-size: 12px; }
.mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; }
.mode-card { grid-template-columns: 25px 1fr 8px; gap: 4px; padding: 4px; } .mode-card { grid-template-columns: 25px 1fr 8px; gap: 4px; padding: 4px; }
.mode-card > i, .mode-card.is-wide > i { width: 23px; height: 23px; font-size: 10px; } .mode-card > i, .mode-card.is-wide > i { width: 23px; height: 23px; font-size: 10px; }
.mode-card strong, .mode-card.is-wide strong { font-size: 8px; } .mode-card strong, .mode-card.is-wide strong { font-size: 8px; }
@@ -2054,6 +2114,40 @@ button:focus-visible {
.collection-drop > p { right: 5px; bottom: 4px; left: 5px; padding-top: 3px; } .collection-drop > p { right: 5px; bottom: 4px; left: 5px; padding-top: 3px; }
.collection-note { margin-top: 5px; padding: 3px 5px; } .collection-note { margin-top: 5px; padding: 3px 5px; }
.collection-note strong, .collection-note small { font-size: 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: 33px; 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; } .settings-layout { gap: 7px; padding-top: 7px; }
.volume-setting { height: 98px; padding: 7px; } .volume-setting { height: 98px; padding: 7px; }
.volume-setting strong { font-size: 8px; } .volume-setting strong { font-size: 8px; }
@@ -2141,6 +2235,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-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-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-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 { 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 > 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; } .gear-preview h2 { margin: 8px 0; font: 500 17px "Cinzel", serif; }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+4
View File
@@ -9,6 +9,9 @@ export default defineConfig({
port: 4173, port: 4173,
strictPort: true, strictPort: true,
allowedHosts: ["iwanttoheal.phenomrom.com"], allowedHosts: ["iwanttoheal.phenomrom.com"],
proxy: {
"/api": "http://127.0.0.1:4174",
},
}, },
preview: { preview: {
host: "0.0.0.0", host: "0.0.0.0",
@@ -18,5 +21,6 @@ export default defineConfig({
}, },
test: { test: {
environment: "node", environment: "node",
include: ["src/**/*.test.ts"],
}, },
}); });