Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
122f159b94 | ||
|
|
35553c18dd |
@@ -1,5 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
data/
|
||||||
|
backups/
|
||||||
game_assets/
|
game_assets/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
vite.config.js
|
vite.config.js
|
||||||
|
|||||||
@@ -96,17 +96,24 @@ Stable locked 60 FPS is a release requirement, not a best-effort goal.
|
|||||||
- Verify the changed code and its direct dependencies. Do not run tests, visual QA, or controller checks for unrelated screens or systems by default.
|
- Verify the changed code and its direct dependencies. Do not run tests, visual QA, or controller checks for unrelated screens or systems by default.
|
||||||
- For a screen change, check that screen's applicable display layouts, browser fallback, and controller path only.
|
- For a screen change, check that screen's applicable display layouts, browser fallback, and controller path only.
|
||||||
- For a reusable mechanic or domain change, run its focused tests plus direct consumers affected by the change.
|
- For a reusable mechanic or domain change, run its focused tests plus direct consumers affected by the change.
|
||||||
|
- Run viewport, display-layout, and browser-fallback testing only when a change adds or modifies a screen, menu, modal, HUD, overlay, or other UI/layout behavior.
|
||||||
|
- Combat rules, encounter mechanics, world-space telegraphs, animations, VFX, tuning, and other gameplay-only changes do not require viewport testing unless they also change UI or screen layout.
|
||||||
- Expand to broader regression or full-suite verification only for shared foundations, cross-cutting changes, risky refactors, release validation, or when explicitly requested.
|
- Expand to broader regression or full-suite verification only for shared foundations, cross-cutting changes, risky refactors, release validation, or when explicitly requested.
|
||||||
- State what was verified and any deliberately unverified scope in the handoff.
|
- State what was verified and any deliberately unverified scope in the handoff.
|
||||||
|
|
||||||
## Definition of done
|
## Definition of done
|
||||||
|
|
||||||
A screen or mechanic is not complete until:
|
A screen or UI change is not complete until:
|
||||||
|
|
||||||
1. Its main-display UI is designed and verified at the approximately `960 x 540` Android CSS/layout viewport, with rendering validated against the `1920 x 1080` physical panel.
|
1. Its main-display UI is designed and verified at the approximately `960 x 540` Android CSS/layout viewport, with rendering validated against the `1920 x 1080` physical panel.
|
||||||
2. Its applicable secondary UI is designed and verified at the approximately `620 x 540` Android CSS/layout viewport, with rendering validated against the `1240 x 1080` physical panel.
|
2. Its applicable secondary UI is designed and verified at the approximately `620 x 540` Android CSS/layout viewport, with rendering validated against the `1240 x 1080` physical panel.
|
||||||
3. It has a complete single-display browser fallback.
|
3. It has a complete single-display browser fallback.
|
||||||
4. Every action is reachable and understandable using only a controller, with no click-to-focus step.
|
4. Every action is reachable and understandable using only a controller, with no click-to-focus step.
|
||||||
5. Shared game logic is modular, typed, and testable outside the renderer/UI.
|
|
||||||
6. It introduces no known resource leak, unbounded work, or avoidable hot-path allocation.
|
Every code change is not complete until:
|
||||||
7. Representative Thor hardware sustains the locked 60 FPS target, or the change includes measured evidence and an explicit approved exception.
|
|
||||||
|
1. Shared game logic is modular, typed, and testable outside the renderer/UI.
|
||||||
|
2. It introduces no known resource leak, unbounded work, or avoidable hot-path allocation.
|
||||||
|
3. Representative Thor hardware sustains the locked 60 FPS target, or the change includes measured evidence and an explicit approved exception.
|
||||||
|
|
||||||
|
Gameplay-only mechanic and domain changes do not inherit the screen/UI viewport requirements above.
|
||||||
|
|||||||
+30
-4
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -116,11 +138,11 @@ outside the repository.
|
|||||||
|
|
||||||
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
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
canonical_username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
password_salt TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sessions_token_hash_idx ON sessions(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hunter_saves (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
|
||||||
|
hunter_name TEXT NOT NULL,
|
||||||
|
save_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (account_id, slot_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boss_kill_records (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
|
||||||
|
boss_id TEXT NOT NULL,
|
||||||
|
kills INTEGER NOT NULL DEFAULT 0 CHECK (kills >= 0),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (account_id, slot_id, boss_id),
|
||||||
|
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS boss_kill_rank_idx
|
||||||
|
ON boss_kill_records (boss_id, kills DESC, updated_at ASC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roguelike_records (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
|
||||||
|
highest_round INTEGER NOT NULL DEFAULT 0 CHECK (highest_round >= 0),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (account_id, slot_id),
|
||||||
|
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS roguelike_rank_idx
|
||||||
|
ON roguelike_records (highest_round DESC, updated_at ASC);
|
||||||
+8
-3
@@ -1,17 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.4",
|
"version": "0.1.6",
|
||||||
"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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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")}`);
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||||
|
import { mkdirSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
|
const AUTH_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const AUTH_ATTEMPTS_PER_WINDOW = 20;
|
||||||
|
const authAttempts = new Map();
|
||||||
|
|
||||||
|
function apiError(message, status = 400) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.status = status;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(response, status, body) {
|
||||||
|
response.statusCode = status;
|
||||||
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
response.setHeader("Cache-Control", "no-store");
|
||||||
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
response.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredCorsOrigins() {
|
||||||
|
return String(process.env.CORS_ORIGINS ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCorsHeaders(request, response) {
|
||||||
|
const origin = request.headers.origin;
|
||||||
|
if (!origin) return;
|
||||||
|
const configured = configuredCorsOrigins();
|
||||||
|
if (!configured.includes("*") && !configured.includes(origin)) return;
|
||||||
|
response.setHeader("Access-Control-Allow-Origin", origin);
|
||||||
|
response.setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type");
|
||||||
|
response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
|
||||||
|
response.setHeader("Access-Control-Max-Age", "86400");
|
||||||
|
response.setHeader("Vary", "Origin");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(request) {
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
for await (const chunk of request) {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_JSON_BYTES) throw apiError("Request body is too large.", 413);
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
throw apiError("Request body must be valid JSON.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalUsername(value) {
|
||||||
|
return String(value ?? "").trim().toLocaleLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUsername(value) {
|
||||||
|
const username = String(value ?? "").trim();
|
||||||
|
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
|
||||||
|
throw apiError("Username must be 3–20 letters, numbers, or underscores.");
|
||||||
|
}
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePassword(value) {
|
||||||
|
const password = String(value ?? "");
|
||||||
|
if (password.length < 10 || password.length > 128) {
|
||||||
|
throw apiError("Password must be 10–128 characters.");
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
function passwordDigest(password, salt) {
|
||||||
|
return scryptSync(password, salt, 64).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password, account) {
|
||||||
|
const actual = Buffer.from(passwordDigest(password, account.passwordSalt), "hex");
|
||||||
|
const expected = Buffer.from(account.passwordHash, "hex");
|
||||||
|
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenHash(token) {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearerToken(request) {
|
||||||
|
const authorization = String(request.headers.authorization ?? "");
|
||||||
|
return authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSession(database, accountId) {
|
||||||
|
const token = randomBytes(32).toString("base64url");
|
||||||
|
const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS).toISOString();
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO sessions (account_id, token_hash, expires_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`).run(accountId, tokenHash(token), expiresAt);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSession(database, request) {
|
||||||
|
const token = bearerToken(request);
|
||||||
|
if (!token) return null;
|
||||||
|
return database.prepare(`
|
||||||
|
SELECT accounts.id AS accountId, accounts.username
|
||||||
|
FROM sessions
|
||||||
|
JOIN accounts ON accounts.id = sessions.account_id
|
||||||
|
WHERE sessions.token_hash = ? AND sessions.expires_at > CURRENT_TIMESTAMP
|
||||||
|
`).get(tokenHash(token)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSession(database, request) {
|
||||||
|
const session = currentSession(database, request);
|
||||||
|
if (!session) throw apiError("Sign in required.", 401);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientAddress(request) {
|
||||||
|
return request.socket?.remoteAddress ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceAuthRateLimit(request) {
|
||||||
|
const now = Date.now();
|
||||||
|
const key = clientAddress(request);
|
||||||
|
const existing = authAttempts.get(key);
|
||||||
|
const bucket = existing && now - existing.startedAt < AUTH_WINDOW_MS
|
||||||
|
? existing
|
||||||
|
: { startedAt: now, count: 0 };
|
||||||
|
bucket.count += 1;
|
||||||
|
authAttempts.set(key, bucket);
|
||||||
|
if (bucket.count > AUTH_ATTEMPTS_PER_WINDOW) {
|
||||||
|
throw apiError("Too many authentication attempts. Try again later.", 429);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function register(database, payload) {
|
||||||
|
const username = validateUsername(payload?.username);
|
||||||
|
const password = validatePassword(payload?.password);
|
||||||
|
const canonical = canonicalUsername(username);
|
||||||
|
if (database.prepare("SELECT id FROM accounts WHERE canonical_username = ?").get(canonical)) {
|
||||||
|
throw apiError("Account already exists.", 409);
|
||||||
|
}
|
||||||
|
const salt = randomBytes(16).toString("hex");
|
||||||
|
const result = database.prepare(`
|
||||||
|
INSERT INTO accounts (username, canonical_username, password_hash, password_salt)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(username, canonical, passwordDigest(password, salt), salt);
|
||||||
|
const accountId = Number(result.lastInsertRowid);
|
||||||
|
return { account: { id: accountId, username }, token: createSession(database, accountId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function login(database, payload) {
|
||||||
|
const canonical = canonicalUsername(payload?.username);
|
||||||
|
const password = String(payload?.password ?? "");
|
||||||
|
const account = database.prepare(`
|
||||||
|
SELECT id, username, password_hash AS passwordHash, password_salt AS passwordSalt
|
||||||
|
FROM accounts WHERE canonical_username = ?
|
||||||
|
`).get(canonical);
|
||||||
|
if (!account || !verifyPassword(password, account)) {
|
||||||
|
throw apiError("Username or password is incorrect.", 401);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
account: { id: account.id, username: account.username },
|
||||||
|
token: createSession(database, account.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSlotId(value) {
|
||||||
|
const slotId = Number(value);
|
||||||
|
if (!Number.isInteger(slotId) || slotId < 1 || slotId > 3) throw apiError("Invalid save slot.");
|
||||||
|
return slotId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSave(value, slotId) {
|
||||||
|
if (!value || typeof value !== "object" || Number(value.schemaVersion) !== 5) {
|
||||||
|
throw apiError("Save snapshot is invalid.");
|
||||||
|
}
|
||||||
|
if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request.");
|
||||||
|
if (typeof value.hunterName !== "string" || !value.hunterName.trim()) {
|
||||||
|
throw apiError("Save snapshot has no hunter name.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonNegativeInteger(value) {
|
||||||
|
const number = Math.floor(Number(value));
|
||||||
|
return Number.isFinite(number) ? Math.max(0, number) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLeaderboardStats(database, accountId, slotId, save) {
|
||||||
|
database.prepare("DELETE FROM boss_kill_records WHERE account_id = ? AND slot_id = ?").run(accountId, slotId);
|
||||||
|
const insertBoss = database.prepare(`
|
||||||
|
INSERT INTO boss_kill_records (account_id, slot_id, boss_id, kills, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
`);
|
||||||
|
const bossKills = save.stats?.bossKills && typeof save.stats.bossKills === "object"
|
||||||
|
? save.stats.bossKills
|
||||||
|
: {};
|
||||||
|
for (const [bossId, rawKills] of Object.entries(bossKills)) {
|
||||||
|
if (!/^[a-z0-9-]{1,64}$/.test(bossId)) continue;
|
||||||
|
const kills = normalizeNonNegativeInteger(rawKills);
|
||||||
|
if (kills > 0) insertBoss.run(accountId, slotId, bossId, kills);
|
||||||
|
}
|
||||||
|
const highestRound = normalizeNonNegativeInteger(save.stats?.highestRoguelikeRound);
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO roguelike_records (account_id, slot_id, highest_round, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(account_id, slot_id) DO UPDATE SET
|
||||||
|
highest_round = excluded.highest_round,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`).run(accountId, slotId, highestRound);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSave(database, accountId, slotId, rawSave) {
|
||||||
|
const save = validateSave(rawSave, slotId);
|
||||||
|
const serialized = JSON.stringify(save);
|
||||||
|
if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413);
|
||||||
|
database.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO hunter_saves (account_id, slot_id, hunter_name, save_json, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(account_id, slot_id) DO UPDATE SET
|
||||||
|
hunter_name = excluded.hunter_name,
|
||||||
|
save_json = excluded.save_json,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`).run(accountId, slotId, save.hunterName.trim().slice(0, 20), serialized);
|
||||||
|
syncLeaderboardStats(database, accountId, slotId, save);
|
||||||
|
database.exec("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
database.exec("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return save;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSave(database, accountId, slotId) {
|
||||||
|
const row = database.prepare(`
|
||||||
|
SELECT save_json AS saveJson FROM hunter_saves WHERE account_id = ? AND slot_id = ?
|
||||||
|
`).get(accountId, slotId);
|
||||||
|
if (!row) return null;
|
||||||
|
try { return JSON.parse(row.saveJson); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function listSaves(database, accountId) {
|
||||||
|
return database.prepare(`
|
||||||
|
SELECT slot_id AS slotId, save_json AS saveJson, updated_at AS updatedAt
|
||||||
|
FROM hunter_saves WHERE account_id = ? ORDER BY slot_id
|
||||||
|
`).all(accountId).flatMap((row) => {
|
||||||
|
try { return [{ slotId: row.slotId, save: JSON.parse(row.saveJson), updatedAt: row.updatedAt }]; }
|
||||||
|
catch { return []; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaderboardEntry(row, valueKey) {
|
||||||
|
return {
|
||||||
|
rank: row.rank,
|
||||||
|
username: row.username,
|
||||||
|
hunterName: row.hunterName,
|
||||||
|
slotId: row.slotId,
|
||||||
|
value: row[valueKey],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bossLeaderboard(database, accountId, slotId, bossId) {
|
||||||
|
if (!/^[a-z0-9-]{1,64}$/.test(bossId)) throw apiError("Invalid boss.");
|
||||||
|
const rows = database.prepare(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
RANK() OVER (ORDER BY records.kills DESC) AS rank,
|
||||||
|
records.account_id AS accountId,
|
||||||
|
records.slot_id AS slotId,
|
||||||
|
records.kills,
|
||||||
|
accounts.username,
|
||||||
|
saves.hunter_name AS hunterName,
|
||||||
|
records.updated_at AS updatedAt
|
||||||
|
FROM boss_kill_records records
|
||||||
|
JOIN accounts ON accounts.id = records.account_id
|
||||||
|
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
|
||||||
|
WHERE records.boss_id = ?
|
||||||
|
)
|
||||||
|
SELECT * FROM ranked ORDER BY kills DESC, updatedAt ASC, accountId ASC, slotId ASC
|
||||||
|
`).all(bossId);
|
||||||
|
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
|
||||||
|
return {
|
||||||
|
kind: "boss",
|
||||||
|
bossId,
|
||||||
|
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "kills")),
|
||||||
|
current: current ? leaderboardEntry(current, "kills") : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function roguelikeLeaderboard(database, accountId, slotId) {
|
||||||
|
const rows = database.prepare(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
RANK() OVER (ORDER BY records.highest_round DESC) AS rank,
|
||||||
|
records.account_id AS accountId,
|
||||||
|
records.slot_id AS slotId,
|
||||||
|
records.highest_round AS highestRound,
|
||||||
|
accounts.username,
|
||||||
|
saves.hunter_name AS hunterName,
|
||||||
|
records.updated_at AS updatedAt
|
||||||
|
FROM roguelike_records records
|
||||||
|
JOIN accounts ON accounts.id = records.account_id
|
||||||
|
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
|
||||||
|
WHERE records.highest_round > 0
|
||||||
|
)
|
||||||
|
SELECT * FROM ranked ORDER BY highestRound DESC, updatedAt ASC, accountId ASC, slotId ASC
|
||||||
|
`).all();
|
||||||
|
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
|
||||||
|
return {
|
||||||
|
kind: "roguelike",
|
||||||
|
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestRound")),
|
||||||
|
current: current ? leaderboardEntry(current, "highestRound") : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGameApiHandler(options = {}) {
|
||||||
|
const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data");
|
||||||
|
mkdirSync(dataDirectory, { recursive: true });
|
||||||
|
const database = new DatabaseSync(resolve(dataDirectory, "game.db"));
|
||||||
|
database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8"));
|
||||||
|
|
||||||
|
async function handle(request, response, next) {
|
||||||
|
if (!request.url?.startsWith("/api/")) return next();
|
||||||
|
setCorsHeaders(request, response);
|
||||||
|
if (request.method === "OPTIONS") {
|
||||||
|
response.statusCode = 204;
|
||||||
|
return response.end();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
database.prepare("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP").run();
|
||||||
|
const url = new URL(request.url, "http://localhost");
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
if (path === "/api/health" && request.method === "GET") {
|
||||||
|
return sendJson(response, 200, { ok: true, database: "ready" });
|
||||||
|
}
|
||||||
|
if (path === "/api/auth/register" && request.method === "POST") {
|
||||||
|
enforceAuthRateLimit(request);
|
||||||
|
return sendJson(response, 201, register(database, await readJson(request)));
|
||||||
|
}
|
||||||
|
if (path === "/api/auth/login" && request.method === "POST") {
|
||||||
|
enforceAuthRateLimit(request);
|
||||||
|
return sendJson(response, 200, login(database, await readJson(request)));
|
||||||
|
}
|
||||||
|
if (path === "/api/auth/session" && request.method === "GET") {
|
||||||
|
const session = currentSession(database, request);
|
||||||
|
return sendJson(response, session ? 200 : 401, session
|
||||||
|
? { account: { id: session.accountId, username: session.username } }
|
||||||
|
: { error: "Sign in required." });
|
||||||
|
}
|
||||||
|
if (path === "/api/auth/logout" && request.method === "POST") {
|
||||||
|
const token = bearerToken(request);
|
||||||
|
if (token) database.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash(token));
|
||||||
|
return sendJson(response, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = requireSession(database, request);
|
||||||
|
if (path === "/api/saves" && request.method === "GET") {
|
||||||
|
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
|
||||||
|
}
|
||||||
|
const saveMatch = path.match(/^\/api\/saves\/([1-3])$/);
|
||||||
|
if (saveMatch && request.method === "GET") {
|
||||||
|
return sendJson(response, 200, { save: readSave(database, session.accountId, validateSlotId(saveMatch[1])) });
|
||||||
|
}
|
||||||
|
if (saveMatch && request.method === "PUT") {
|
||||||
|
const slotId = validateSlotId(saveMatch[1]);
|
||||||
|
const payload = await readJson(request);
|
||||||
|
return sendJson(response, 200, { save: writeSave(database, session.accountId, slotId, payload?.save) });
|
||||||
|
}
|
||||||
|
const bossMatch = path.match(/^\/api\/leaderboards\/boss\/([a-z0-9-]+)$/);
|
||||||
|
if (bossMatch && request.method === "GET") {
|
||||||
|
const slotId = validateSlotId(url.searchParams.get("slot"));
|
||||||
|
return sendJson(response, 200, bossLeaderboard(database, session.accountId, slotId, bossMatch[1]));
|
||||||
|
}
|
||||||
|
if (path === "/api/leaderboards/roguelike" && request.method === "GET") {
|
||||||
|
const slotId = validateSlotId(url.searchParams.get("slot"));
|
||||||
|
return sendJson(response, 200, roguelikeLeaderboard(database, session.accountId, slotId));
|
||||||
|
}
|
||||||
|
return sendJson(response, 404, { error: "API route not found." });
|
||||||
|
} catch (error) {
|
||||||
|
const status = Number(error?.status) || 500;
|
||||||
|
const message = status >= 500 ? "Server error." : error.message;
|
||||||
|
if (status >= 500) console.error(error);
|
||||||
|
return sendJson(response, status, { error: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handle, close: () => database.close() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
import { createGameApiHandler } from "./game-api.mjs";
|
||||||
|
|
||||||
|
const dataDirectory = mkdtempSync(join(tmpdir(), "iwt-heal-api-"));
|
||||||
|
const api = createGameApiHandler({ dataDirectory });
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
void api.handle(request, response, () => {
|
||||||
|
response.statusCode = 404;
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let baseUrl;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const address = server.address();
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
api.close();
|
||||||
|
rmSync(dataDirectory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function json(path, init = {}) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, init);
|
||||||
|
const body = await response.json();
|
||||||
|
return { response, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 5,
|
||||||
|
slotId,
|
||||||
|
hunterName,
|
||||||
|
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("health endpoint reports persistent database readiness", async () => {
|
||||||
|
const { response, body } = await json("/api/health");
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(body, { ok: true, database: "ready" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accounts, server saves, and top-five plus current rankings work end to end", async () => {
|
||||||
|
const players = [];
|
||||||
|
for (let index = 0; index < 6; index += 1) {
|
||||||
|
const username = `hunter_${index}`;
|
||||||
|
const registration = await json("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password: `long-password-${index}` }),
|
||||||
|
});
|
||||||
|
assert.equal(registration.response.status, 201);
|
||||||
|
const token = registration.body.token;
|
||||||
|
const kills = 60 - index * 10;
|
||||||
|
const highestRound = 30 - index * 4;
|
||||||
|
const upload = await json("/api/saves/1", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound) }),
|
||||||
|
});
|
||||||
|
assert.equal(upload.response.status, 200);
|
||||||
|
players.push({ token, kills, highestRound });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = players[5];
|
||||||
|
const bossBoard = await json("/api/leaderboards/boss/bulldrome?slot=1", {
|
||||||
|
headers: { Authorization: `Bearer ${current.token}` },
|
||||||
|
});
|
||||||
|
assert.equal(bossBoard.body.top.length, 5);
|
||||||
|
assert.equal(bossBoard.body.top[0].value, 60);
|
||||||
|
assert.equal(bossBoard.body.current.rank, 6);
|
||||||
|
assert.equal(bossBoard.body.current.value, current.kills);
|
||||||
|
|
||||||
|
const rogueBoard = await json("/api/leaderboards/roguelike?slot=1", {
|
||||||
|
headers: { Authorization: `Bearer ${current.token}` },
|
||||||
|
});
|
||||||
|
assert.equal(rogueBoard.body.top.length, 5);
|
||||||
|
assert.equal(rogueBoard.body.current.rank, 6);
|
||||||
|
assert.equal(rogueBoard.body.current.value, current.highestRound);
|
||||||
|
|
||||||
|
const download = await json("/api/saves/1", {
|
||||||
|
headers: { Authorization: `Bearer ${current.token}` },
|
||||||
|
});
|
||||||
|
assert.equal(download.body.save.hunterName, "Hero 5");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid credentials cannot access server saves", async () => {
|
||||||
|
const login = await json("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "hunter_0", password: "incorrect-password" }),
|
||||||
|
});
|
||||||
|
assert.equal(login.response.status, 401);
|
||||||
|
const saves = await json("/api/saves");
|
||||||
|
assert.equal(saves.response.status, 401);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
+14
-7
@@ -32,20 +32,24 @@ 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 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();
|
||||||
@@ -81,13 +85,16 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
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 = `boss-0-${state.boss.id}`;
|
||||||
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
|
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
|
||||||
rewardedBossInstances.current.add(primaryInstanceId);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,15 +105,15 @@ export default function App() {
|
|||||||
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue;
|
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue;
|
||||||
rewardedBossInstances.current.add(entry.instanceId);
|
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]);
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ABILITY_ORDER } from "../game/data";
|
|||||||
import { HEALER_CLASSES } from "../game/healers";
|
import { HEALER_CLASSES } from "../game/healers";
|
||||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||||
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||||
|
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
||||||
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";
|
||||||
@@ -85,12 +86,16 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
|
|||||||
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
|
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
|
||||||
const activeCast = useGameStore((state) => state.activeCast);
|
const activeCast = useGameStore((state) => state.activeCast);
|
||||||
const castAbility = useGameStore((state) => state.castAbility);
|
const castAbility = useGameStore((state) => state.castAbility);
|
||||||
|
const runModifiers = useGameStore((state) => state.runModifiers);
|
||||||
const remaining = abilityRemaining(abilityId, time, cooldowns);
|
const remaining = abilityRemaining(abilityId, time, cooldowns);
|
||||||
|
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
|
||||||
|
const castTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
|
||||||
|
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
|
||||||
const globalRemaining = Math.max(0, globalCooldownUntil - time);
|
const globalRemaining = Math.max(0, globalCooldownUntil - time);
|
||||||
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
|
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
|
||||||
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
|
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
|
||||||
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < ability.mana || noDispel || invalidTarget;
|
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
|
||||||
const resourceCopy = `${ability.mana ? `${ability.mana} mana` : "free"}${ability.castTime ? ` · ${ability.castTime.toFixed(1)}s` : ""}`;
|
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -106,7 +111,7 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
|
|||||||
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
|
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
|
||||||
<span className="ability-pad">{ability.gamepad}</span>
|
<span className="ability-pad">{ability.gamepad}</span>
|
||||||
{remaining > 0 && (
|
{remaining > 0 && (
|
||||||
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / ability.cooldown) } as React.CSSProperties}>
|
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
|
||||||
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
|
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,25 +1,46 @@
|
|||||||
import { RUN_BUFFS, bossHealthMultiplier, countRunBuff } from "../game/roguelike";
|
import { useEffect, useState } from "react";
|
||||||
import { useGameStore } from "../game/store";
|
import { ROGUE_TRIALS_TRIO_ROUND, RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike";
|
||||||
|
import { HEALER_CLASSES } from "../game/healers";
|
||||||
|
import { isRunBuffInputLocked, useGameStore } from "../game/store";
|
||||||
|
|
||||||
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 runBuffs = useGameStore((state) => state.runBuffs);
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
|
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||||
|
const runBuffRanks = useGameStore((state) => state.runBuffRanks);
|
||||||
|
const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId);
|
||||||
const choices = useGameStore((state) => state.draftBuffIds);
|
const 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 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 [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">
|
<div className={`buff-choice-grid choice-count-${choices.length}`}>
|
||||||
{choices.map((buffId) => {
|
{choices.length > 0 ? choices.map((buffId) => {
|
||||||
const buff = RUN_BUFFS[buffId];
|
const buff = RUN_BUFFS[buffId];
|
||||||
const stacks = countRunBuff(runBuffs, buffId);
|
const rank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId);
|
||||||
|
const nextRank = Math.min(buff.maxRank, rank + 1);
|
||||||
|
const ability = abilities[buff.abilityId];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={buffId}
|
key={buffId}
|
||||||
@@ -28,17 +49,25 @@ 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>
|
||||||
<span><small>{stacks ? `${stacks} owned` : "New blessing"}</small><strong>{buff.name}</strong></span>
|
<span><small>{rank ? `Rank ${rank} → ${nextRank} / ${buff.maxRank}` : `New blessing · Rank 1 / ${buff.maxRank}`}</small><strong>{ability.shortName}: {buff.name}</strong></span>
|
||||||
<b>{buff.summary}</b>
|
<b>{formatRunBuffEffect(buffId, nextRank)}</b>
|
||||||
<p>{buff.detail}</p>
|
<p>{buff.detail}</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
}) : (
|
||||||
|
<button className="buff-mastery-continue is-controller-focused" onClick={continueRun} disabled={inputLocked}>
|
||||||
|
<i>✦</i>
|
||||||
|
<span><small>Full mastery</small><strong>Continue Without Buff</strong></span>
|
||||||
|
<b>All 18 blessings reached maximum rank.</b>
|
||||||
|
<p>Keep completed build and begin next randomized encounter.</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<footer><b>← / →</b> Choose <i /> <b>A / ENTER</b> Claim</footer>
|
<footer>{inputLocked ? <b>Choices ready in a moment…</b> : <>{choices.length > 0 && <><b>← / →</b> Choose <i /></>} <b>A / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+268
-83
@@ -1,13 +1,15 @@
|
|||||||
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";
|
||||||
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
|
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
|
||||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||||
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
|
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
|
||||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID } from "../game/bossCatalog";
|
import { ABILITY_ORDER } from "../game/data";
|
||||||
import { selectRandomBossPair } from "../game/roguelike";
|
import { BOSS_DEFINITIONS, BOSS_GROUP_BY_ID, BOSS_GROUPS } from "../game/bossCatalog";
|
||||||
import type { BossId } from "../game/types";
|
import { bossMechanicIsPassive, bossMechanicName } from "../game/bosses/mechanicPool";
|
||||||
|
import { RUN_BUFFS, formatRunBuffEffect, selectRandomBossPair } from "../game/roguelike";
|
||||||
|
import type { AbilityId, BossId } from "../game/types";
|
||||||
import {
|
import {
|
||||||
GEAR_OWNER_LABELS,
|
GEAR_OWNER_LABELS,
|
||||||
GEAR_OWNER_ORDER,
|
GEAR_OWNER_ORDER,
|
||||||
@@ -17,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 {
|
||||||
@@ -31,8 +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";
|
||||||
|
|
||||||
|
const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait })));
|
||||||
|
|
||||||
function FocusButton({
|
function FocusButton({
|
||||||
id,
|
id,
|
||||||
focusedId,
|
focusedId,
|
||||||
@@ -73,6 +80,7 @@ function ControllerLegend({ back = false }: { back?: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -81,6 +89,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() },
|
||||||
@@ -113,6 +127,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>
|
||||||
@@ -125,6 +142,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">
|
||||||
@@ -148,8 +167,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>
|
||||||
@@ -337,6 +356,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" },
|
||||||
@@ -349,10 +369,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" } },
|
||||||
@@ -422,37 +443,119 @@ function HomeScreen() {
|
|||||||
|
|
||||||
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<BossId | "roguelike">("roguelike");
|
||||||
|
const [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null);
|
||||||
|
const [leaderboardStatus, setLeaderboardStatus] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedStat === "roguelike" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return;
|
||||||
|
setSelectedStat(collection?.bosses[0]?.bossId ?? "roguelike");
|
||||||
|
}, [collection, selectedStat]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hunter || collectionView !== "stats") return;
|
||||||
|
if (!accountId) {
|
||||||
|
setLeaderboard(null);
|
||||||
|
setLeaderboardStatus("Sign in to view overall rankings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLeaderboardStatus("Loading overall rankings…");
|
||||||
|
const request = selectedStat === "roguelike"
|
||||||
|
? onlineRepository.roguelikeLeaderboard(hunter.slotId)
|
||||||
|
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId);
|
||||||
|
void request.then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLeaderboard(result);
|
||||||
|
setLeaderboardStatus("");
|
||||||
|
}).catch((error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLeaderboard(null);
|
||||||
|
setLeaderboardStatus(error instanceof Error ? error.message : "Leaderboard unavailable.");
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [accountId, collectionView, hunter, selectedStat]);
|
||||||
const actions = useMemo<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-${collection.bosses[0].bossId}` } },
|
||||||
|
...collection.bosses.map((boss, index) => ({
|
||||||
|
id: `stat-${boss.bossId}`,
|
||||||
|
run: () => setSelectedStat(boss.bossId),
|
||||||
|
neighbors: {
|
||||||
|
up: index === 0 ? "stat-roguelike" : `stat-${collection.bosses[index - 1].bossId}`,
|
||||||
|
down: index === collection.bosses.length - 1 ? `group-${collection.groupId}` : `stat-${collection.bosses[index + 1].bossId}`,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
] : []),
|
||||||
...collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })),
|
...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;
|
||||||
|
|
||||||
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")}>B · 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>Highest roguelike round {hunter.stats.highestRoguelikeRound}</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>
|
||||||
|
{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>{selectedStat === "roguelike" ? "Roguelike rounds" : BOSS_DEFINITIONS[selectedStat].name}</strong></span><b>{selectedStat === "roguelike" ? `${hunter.stats.highestRoguelikeRound} best` : `${hunter.stats.bossKills[selectedStat] ?? 0} 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>{selectedStat === "roguelike" ? hunter.stats.highestRoguelikeRound : hunter.stats.bossKills[selectedStat] ?? 0}</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={
|
||||||
@@ -463,6 +566,7 @@ 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>
|
||||||
</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)}>
|
||||||
@@ -483,17 +587,35 @@ function GearScreen() {
|
|||||||
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
|
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
|
||||||
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
|
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
|
||||||
const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId);
|
const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId);
|
||||||
|
const selectedPassiveAbilityId = useFrontendStore((state) => state.selectedPassiveAbilityId);
|
||||||
|
const selectedPassiveInfusionId = useFrontendStore((state) => state.selectedPassiveInfusionId);
|
||||||
const selectOwner = useFrontendStore((state) => state.selectGearOwner);
|
const selectOwner = useFrontendStore((state) => state.selectGearOwner);
|
||||||
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
|
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
|
||||||
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
|
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
|
||||||
const selectInfusion = useFrontendStore((state) => state.selectInfusion);
|
const selectInfusion = useFrontendStore((state) => state.selectInfusion);
|
||||||
|
const selectPassiveAbility = useFrontendStore((state) => state.selectPassiveAbility);
|
||||||
|
const selectPassiveInfusion = useFrontendStore((state) => state.selectPassiveInfusion);
|
||||||
const upgrade = useFrontendStore((state) => state.upgradeSelectedGear);
|
const upgrade = useFrontendStore((state) => state.upgradeSelectedGear);
|
||||||
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) : [];
|
||||||
@@ -503,6 +625,10 @@ function GearScreen() {
|
|||||||
const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts));
|
const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts));
|
||||||
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
|
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
|
||||||
const healerOwner = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman";
|
const healerOwner = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman";
|
||||||
|
const passiveHealerClassId = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman" ? selectedOwnerId : "priest";
|
||||||
|
const passiveChoices = PASSIVE_INFUSIONS.filter((passive) => passive.abilityId === selectedPassiveAbilityId);
|
||||||
|
const selectedPassive = RUN_BUFFS[selectedPassiveInfusionId];
|
||||||
|
const healerAbilities = HEALER_CLASSES[passiveHealerClassId].abilities;
|
||||||
const previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`;
|
const previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`;
|
||||||
const actions = useMemo<MenuAction[]>(() => [
|
const actions = useMemo<MenuAction[]>(() => [
|
||||||
...GEAR_OWNER_ORDER.map((ownerId, index) => ({
|
...GEAR_OWNER_ORDER.map((ownerId, index) => ({
|
||||||
@@ -518,7 +644,7 @@ function GearScreen() {
|
|||||||
id: `slot-${slotId}`,
|
id: `slot-${slotId}`,
|
||||||
run: () => selectSlot(slotId),
|
run: () => selectSlot(slotId),
|
||||||
neighbors: {
|
neighbors: {
|
||||||
up: index === 0 ? "back" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
|
up: index === 0 ? "workshop-upgrade" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
|
||||||
down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`,
|
down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`,
|
||||||
left: `owner-${selectedOwnerId}`,
|
left: `owner-${selectedOwnerId}`,
|
||||||
right: previewEntryId,
|
right: previewEntryId,
|
||||||
@@ -531,25 +657,42 @@ function GearScreen() {
|
|||||||
run: () => selectInfusion(infusion.id),
|
run: () => selectInfusion(infusion.id),
|
||||||
neighbors: {
|
neighbors: {
|
||||||
up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`,
|
up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`,
|
||||||
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-${PASSIVE_INFUSIONS[0].id}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
|
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
|
||||||
left: `slot-${selectedSlotId}`,
|
left: `slot-${selectedSlotId}`,
|
||||||
|
right: index === infusionChoices.length - 1 ? "install-infusion" : undefined,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
...(healerOwner ? PASSIVE_INFUSIONS.map((passive, index) => ({
|
...(healerOwner ? ABILITY_ORDER.map((abilityId, index) => ({
|
||||||
|
id: `passive-ability-${abilityId}`,
|
||||||
|
run: () => selectPassiveAbility(abilityId),
|
||||||
|
neighbors: {
|
||||||
|
up: index < 3 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-ability-${ABILITY_ORDER[index - 3]}`,
|
||||||
|
down: index < 3
|
||||||
|
? `passive-ability-${ABILITY_ORDER[index + 3]}`
|
||||||
|
: `passive-${passiveChoices[Math.min(index - 3, passiveChoices.length - 1)].id}`,
|
||||||
|
left: index % 3 > 0 ? `passive-ability-${ABILITY_ORDER[index - 1]}` : `slot-${selectedSlotId}`,
|
||||||
|
right: index % 3 < 2 ? `passive-ability-${ABILITY_ORDER[index + 1]}` : undefined,
|
||||||
|
},
|
||||||
|
})) : []),
|
||||||
|
...(healerOwner ? passiveChoices.map((passive, index) => ({
|
||||||
id: `passive-${passive.id}`,
|
id: `passive-${passive.id}`,
|
||||||
run: () => installPassive(passive.id),
|
run: () => {
|
||||||
|
selectPassiveInfusion(passive.id);
|
||||||
|
installPassive(passive.id);
|
||||||
|
},
|
||||||
enabled: passiveUnlocked,
|
enabled: passiveUnlocked,
|
||||||
neighbors: {
|
neighbors: {
|
||||||
up: index === 0 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-${PASSIVE_INFUSIONS[index - 1].id}`,
|
up: index === 0 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index - 1].id}`,
|
||||||
down: index === PASSIVE_INFUSIONS.length - 1 ? "install-infusion" : `passive-${PASSIVE_INFUSIONS[index + 1].id}`,
|
down: index === passiveChoices.length - 1 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index + 1].id}`,
|
||||||
left: `slot-${selectedSlotId}`,
|
left: `slot-${selectedSlotId}`,
|
||||||
},
|
},
|
||||||
})) : []),
|
})) : []),
|
||||||
{ id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `slot-${selectedSlotId}` } },
|
{ id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `slot-${selectedSlotId}` } },
|
||||||
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: healerOwner ? `passive-${PASSIVE_INFUSIONS[PASSIVE_INFUSIONS.length - 1].id}` : `infusion-${infusionChoices[infusionChoices.length - 1].id}` } },
|
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: `infusion-${infusionChoices[infusionChoices.length - 1].id}`, down: healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : undefined } },
|
||||||
{ id: "back", run: () => navigate("home"), neighbors: { down: `owner-${GEAR_OWNER_ORDER[0]}` } },
|
{ id: "back", run: () => navigate("home"), neighbors: { left: "workshop-infusion", down: `owner-${GEAR_OWNER_ORDER[0]}` } },
|
||||||
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectSlot, selectWorkshopMode, selectedOwnerId, selectedSlotId, upgrade]);
|
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveChoices, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectPassiveAbility, selectPassiveInfusion, selectSlot, selectWorkshopMode, selectedOwnerId, selectedPassiveAbilityId, selectedSlotId, upgrade]);
|
||||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||||
|
const passiveContext = workshopMode === "infusion" && healerOwner && controller.focusedId.startsWith("passive-");
|
||||||
if (!hunter || !slot) return null;
|
if (!hunter || !slot) return null;
|
||||||
const currentBonus = gearBonusText(recipe.statId, slot.level);
|
const currentBonus = gearBonusText(recipe.statId, slot.level);
|
||||||
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
|
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
|
||||||
@@ -563,14 +706,16 @@ function GearScreen() {
|
|||||||
<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">
|
||||||
@@ -585,7 +730,25 @@ function GearScreen() {
|
|||||||
<div className="gear-infusion-options">
|
<div className="gear-infusion-options">
|
||||||
{infusionChoices.map((infusion) => <FocusButton key={infusion.id} id={`infusion-${infusion.id}`} focusedId={controller.focusedId} focus={controller.focus} className={`${infusion.id === selectedInfusion.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "is-equipped" : ""}`} onClick={() => selectInfusion(infusion.id)}><i>{infusion.icon}</i><span><strong>{infusion.name}</strong><small>{infusion.description}</small></span><b>{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""}</b></FocusButton>)}
|
{infusionChoices.map((infusion) => <FocusButton key={infusion.id} id={`infusion-${infusion.id}`} focusedId={controller.focusedId} focus={controller.focus} className={`${infusion.id === selectedInfusion.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "is-equipped" : ""}`} onClick={() => selectInfusion(infusion.id)}><i>{infusion.icon}</i><span><strong>{infusion.name}</strong><small>{infusion.description}</small></span><b>{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""}</b></FocusButton>)}
|
||||||
</div>
|
</div>
|
||||||
{healerOwner && <div className="gear-passive-options"><span>Passive · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>{PASSIVE_INFUSIONS.map((passive) => <FocusButton key={passive.id} id={`passive-${passive.id}`} focusedId={controller.focusedId} focus={controller.focus} disabled={!passiveUnlocked} className={hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""} onClick={() => installPassive(passive.id)}><i>{passive.icon}</i><span><strong>{passive.name}</strong><small>{passive.summary}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}</div>}
|
{healerOwner && <div className="gear-passive-options">
|
||||||
|
<span>Passive blessing · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>
|
||||||
|
<div className="gear-passive-ability-filter">
|
||||||
|
{ABILITY_ORDER.map((abilityId) => <FocusButton key={abilityId} id={`passive-ability-${abilityId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedPassiveAbilityId === abilityId ? "is-selected" : ""} onClick={() => selectPassiveAbility(abilityId)}>{healerAbilities[abilityId].shortName}</FocusButton>)}
|
||||||
|
</div>
|
||||||
|
<div className="gear-passive-choice-list">
|
||||||
|
{passiveChoices.map((passive) => <FocusButton
|
||||||
|
key={passive.id}
|
||||||
|
id={`passive-${passive.id}`}
|
||||||
|
focusedId={controller.focusedId}
|
||||||
|
focus={controller.focus}
|
||||||
|
disabled={!passiveUnlocked}
|
||||||
|
className={`${selectedPassiveInfusionId === passive.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""}`}
|
||||||
|
onFocus={() => selectPassiveInfusion(passive.id)}
|
||||||
|
onPointerEnter={() => selectPassiveInfusion(passive.id)}
|
||||||
|
onClick={() => { selectPassiveInfusion(passive.id); installPassive(passive.id); }}
|
||||||
|
><i>{passive.icon}</i><span><strong>{healerAbilities[passive.abilityId].shortName}: {passive.name}</strong><small>{formatRunBuffEffect(passive.id, 1)}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
</article>}
|
</article>}
|
||||||
</div>
|
</div>
|
||||||
<ControllerLegend back />
|
<ControllerLegend back />
|
||||||
@@ -593,15 +756,15 @@ function GearScreen() {
|
|||||||
}
|
}
|
||||||
bottom={
|
bottom={
|
||||||
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
|
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
|
||||||
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
|
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : passiveContext ? `${healerAbilities[selectedPassive.abilityId].name}: ${selectedPassive.name}` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
|
||||||
<div className="gear-costs">
|
<div className="gear-costs">
|
||||||
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
|
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : passiveContext ? "Passive blessing · Rank 1" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
|
||||||
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
|
{passiveContext ? <article className={passiveUnlocked ? "is-met" : "is-missing"}><i>{passiveUnlocked ? "✓" : "×"}</i><span><strong>{formatRunBuffEffect(selectedPassive.id, 1)}</strong><small>{selectedPassive.detail}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "EQUIPPED" : "RANK 1"}</b></article> : (workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
|
||||||
const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0;
|
const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0;
|
||||||
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> : <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" : "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>}
|
||||||
<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>
|
||||||
}
|
}
|
||||||
@@ -647,7 +810,7 @@ function SettingsScreen() {
|
|||||||
<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="y">Y</i><span><i className="x">X</i><b>●</b><i className="b">B</i></span><i className="a">A</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>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>Right stick</b> Rotate camera</span><span><b>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>
|
||||||
}
|
}
|
||||||
@@ -664,55 +827,62 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
|
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
|
||||||
const navigate = useFrontendStore((state) => state.navigate);
|
const navigate = useFrontendStore((state) => state.navigate);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const bossPageSize = 12;
|
|
||||||
const [bossPage, setBossPage] = useState(() => Math.max(0, Math.floor(AVAILABLE_BOSS_IDS.indexOf(selectedBossId) / bossPageSize)));
|
|
||||||
const mode = MODE_COPY[modeId];
|
const mode = MODE_COPY[modeId];
|
||||||
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
|
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
|
||||||
const progress = hunter?.healers[hunter.activeClassId];
|
const progress = hunter?.healers[hunter.activeClassId];
|
||||||
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 bossPageCount = Math.ceil(AVAILABLE_BOSS_IDS.length / bossPageSize);
|
const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId];
|
||||||
const visibleBossIds = AVAILABLE_BOSS_IDS.slice(bossPage * bossPageSize, (bossPage + 1) * bossPageSize);
|
const visibleBossIds = selectedBossGroup.bossIds;
|
||||||
const bossGridRows = Math.ceil(visibleBossIds.length / 3);
|
const bossGridColumns = Math.min(2, visibleBossIds.length);
|
||||||
const bossGridColumns = Math.ceil(visibleBossIds.length / bossGridRows);
|
const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
|
||||||
const changeBossPage = (nextPage: number) => {
|
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
|
||||||
const page = Math.max(0, Math.min(bossPageCount - 1, nextPage));
|
|
||||||
setBossPage(page);
|
|
||||||
selectBoss(AVAILABLE_BOSS_IDS[page * bossPageSize]);
|
|
||||||
};
|
};
|
||||||
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 ? visibleBossIds.map((bossId, index) => {
|
...(isDungeon ? BOSS_GROUPS.map((group, index) => {
|
||||||
const column = Math.floor(index / bossGridRows);
|
const groupColumns = 5;
|
||||||
const row = index % bossGridRows;
|
const row = Math.floor(index / groupColumns);
|
||||||
const neighborInColumn = (targetColumn: number) => {
|
const column = index % groupColumns;
|
||||||
const columnStart = targetColumn * bossGridRows;
|
const groupAt = (targetRow: number, targetColumn: number) => BOSS_GROUPS[targetRow * groupColumns + targetColumn];
|
||||||
if (columnStart >= visibleBossIds.length || targetColumn < 0) return undefined;
|
|
||||||
const columnEnd = Math.min(columnStart + bossGridRows, visibleBossIds.length) - 1;
|
return {
|
||||||
return `boss-${visibleBossIds[Math.min(columnStart + row, columnEnd)]}`;
|
id: `boss-group-${group.id}`,
|
||||||
|
run: () => selectBossGroup(group.id),
|
||||||
|
neighbors: {
|
||||||
|
up: row > 0 ? `boss-group-${groupAt(row - 1, column)?.id}` : "back",
|
||||||
|
down: groupAt(row + 1, column)
|
||||||
|
? `boss-group-${groupAt(row + 1, column)?.id}`
|
||||||
|
: group.id === selectedBossGroup.id ? `boss-${selectedBossGroup.bossIds[0]}` : undefined,
|
||||||
|
left: column > 0 ? `boss-group-${groupAt(row, column - 1)?.id}` : undefined,
|
||||||
|
right: groupAt(row, column + 1) ? `boss-group-${groupAt(row, column + 1)?.id}` : undefined,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
}) : []),
|
||||||
|
...(isDungeon ? visibleBossIds.map((bossId, index) => {
|
||||||
|
const row = Math.floor(index / bossGridColumns);
|
||||||
|
const column = index % bossGridColumns;
|
||||||
|
const bossAt = (targetRow: number, targetColumn: number) => visibleBossIds[targetRow * bossGridColumns + targetColumn];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `boss-${bossId}`,
|
id: `boss-${bossId}`,
|
||||||
run: () => selectBoss(bossId),
|
run: () => selectBoss(bossId),
|
||||||
neighbors: {
|
neighbors: {
|
||||||
up: row > 0 ? `boss-${visibleBossIds[index - 1]}` : "back",
|
up: row > 0 ? `boss-${bossAt(row - 1, column)}` : `boss-group-${selectedBossGroup.id}`,
|
||||||
down: index + 1 < Math.min((column + 1) * bossGridRows, visibleBossIds.length)
|
down: bossAt(row + 1, column) ? `boss-${bossAt(row + 1, column)}` : `difficulty-${DIFFICULTIES[0].slug}`,
|
||||||
? `boss-${visibleBossIds[index + 1]}`
|
left: column > 0 ? `boss-${bossAt(row, column - 1)}` : undefined,
|
||||||
: `difficulty-${DIFFICULTIES[0].slug}`,
|
right: bossAt(row, column + 1) ? `boss-${bossAt(row, column + 1)}` : undefined,
|
||||||
left: neighborInColumn(column - 1) ?? (bossPage > 0 ? "boss-page-prev" : undefined),
|
|
||||||
right: neighborInColumn(column + 1) ?? (bossPage < bossPageCount - 1 ? "boss-page-next" : undefined),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}) : []),
|
}) : []),
|
||||||
...(isDungeon && bossPage > 0 ? [{ id: "boss-page-prev", run: () => changeBossPage(bossPage - 1), neighbors: { right: `boss-${visibleBossIds[0]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
|
|
||||||
...(isDungeon && bossPage < bossPageCount - 1 ? [{ id: "boss-page-next", run: () => changeBossPage(bossPage + 1), neighbors: { left: `boss-${visibleBossIds[visibleBossIds.length - 1]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
|
|
||||||
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
|
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
|
||||||
id: `difficulty-${difficulty.slug}`,
|
id: `difficulty-${difficulty.slug}`,
|
||||||
run: () => selectDifficulty(difficulty.slug),
|
run: () => selectDifficulty(difficulty.slug),
|
||||||
@@ -724,16 +894,22 @@ 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-${AVAILABLE_BOSS_IDS[0]}` } : { down: "launch" } },
|
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } },
|
||||||
], [bossGridRows, bossPage, bossPageCount, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, 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],
|
||||||
[selectedBoss.mechanics[0], selectedBoss.briefing],
|
[bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing],
|
||||||
[selectedBoss.mechanics[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."],
|
||||||
|
["Trial victory", "Defeat all three final bosses together to complete Rogue Trials."],
|
||||||
|
]
|
||||||
: 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."],
|
||||||
@@ -753,17 +929,26 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
{!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">
|
||||||
<div className="boss-picker-heading">
|
<div className="boss-picker-heading"><span>Choose a mechanic group</span></div>
|
||||||
<span>Choose encounter · Page {bossPage + 1}/{bossPageCount}</span>
|
<div className="boss-group-grid" aria-label="Choose boss group">
|
||||||
<div>
|
{BOSS_GROUPS.map((group) => (
|
||||||
<FocusButton id="boss-page-prev" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === 0} onClick={() => changeBossPage(bossPage - 1)}>◀ Previous</FocusButton>
|
<FocusButton
|
||||||
<FocusButton id="boss-page-next" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === bossPageCount - 1} onClick={() => changeBossPage(bossPage + 1)}>Next ▶</FocusButton>
|
key={group.id}
|
||||||
</div>
|
id={`boss-group-${group.id}`}
|
||||||
|
focusedId={controller.focusedId}
|
||||||
|
focus={controller.focus}
|
||||||
|
className={`boss-group-choice ${group.id === selectedBossGroup.id ? "is-selected" : ""}`}
|
||||||
|
aria-pressed={group.id === selectedBossGroup.id}
|
||||||
|
onClick={() => selectBossGroup(group.id)}
|
||||||
|
>
|
||||||
|
<b>{group.letter}</b><span><strong>Group {group.letter}</strong><small>{group.name}</small></span>
|
||||||
|
</FocusButton>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows, "--boss-grid-columns": bossGridColumns } as React.CSSProperties}>
|
<div className="boss-group-heading"><span>Group {selectedBossGroup.letter} · {selectedBossGroup.name}</span><small>{selectedBossGroup.coreMechanic} mechanics · {visibleBossIds.length} guardians</small></div>
|
||||||
|
<div className="boss-choice-grid">
|
||||||
{visibleBossIds.map((bossId) => {
|
{visibleBossIds.map((bossId) => {
|
||||||
const boss = BOSS_DEFINITIONS[bossId];
|
const boss = BOSS_DEFINITIONS[bossId];
|
||||||
const group = BOSS_GROUP_BY_ID[boss.groupId];
|
|
||||||
return (
|
return (
|
||||||
<FocusButton
|
<FocusButton
|
||||||
key={bossId}
|
key={bossId}
|
||||||
@@ -775,7 +960,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
aria-pressed={selectedBossId === bossId}
|
aria-pressed={selectedBossId === bossId}
|
||||||
onClick={() => selectBoss(bossId)}
|
onClick={() => selectBoss(bossId)}
|
||||||
>
|
>
|
||||||
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>Group {group.letter} · {group.name} · {boss.mechanics[0]}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
|
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanicIds.filter((id) => !bossMechanicIsPassive(id)).map(bossMechanicName).join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
|
||||||
</FocusButton>
|
</FocusButton>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+62
-268
@@ -6,6 +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 {
|
||||||
|
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,
|
||||||
@@ -16,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,
|
||||||
@@ -427,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;
|
||||||
@@ -450,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]);
|
||||||
@@ -475,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);
|
||||||
@@ -517,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) {
|
||||||
@@ -598,7 +600,9 @@ function BossFallback({ bossIndex }: { bossIndex: number }) {
|
|||||||
|
|
||||||
function BullBoss({ bossIndex }: { bossIndex: number }) {
|
function BullBoss({ bossIndex }: { bossIndex: number }) {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
|
const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
|
||||||
|
const motionMode = motion?.mode ?? "holding";
|
||||||
|
const animationCue = motion ? bossAnimationCue(motion) : "idle";
|
||||||
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
|
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
|
||||||
const defeated = bossHp <= 0;
|
const defeated = bossHp <= 0;
|
||||||
const group = useRef<THREE.Group>(null);
|
const group = useRef<THREE.Group>(null);
|
||||||
@@ -618,15 +622,13 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
|
|||||||
|
|
||||||
const clipName = phase === "victory" || defeated
|
const clipName = phase === "victory" || defeated
|
||||||
? "Death"
|
? "Death"
|
||||||
: motionMode === "telegraph"
|
: animationCue === "attack"
|
||||||
? "Idle_Headlow"
|
? "Idle_Headlow"
|
||||||
: motionMode === "pouncing"
|
: animationCue === "special"
|
||||||
? "Gallop_Jump"
|
? "Gallop_Jump"
|
||||||
: motionMode === "charging" || motionMode === "returning"
|
: animationCue === "move"
|
||||||
? "Gallop"
|
? "Gallop"
|
||||||
: motionMode === "stacking"
|
: "Idle";
|
||||||
? "Idle_Headlow"
|
|
||||||
: "Idle";
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const next = actions[clipName];
|
const next = actions[clipName];
|
||||||
@@ -650,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;
|
||||||
@@ -661,9 +664,6 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
|
|||||||
if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") {
|
if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") {
|
||||||
facingX = motion.chargeEnd[0] - motion.chargeStart[0];
|
facingX = motion.chargeEnd[0] - motion.chargeStart[0];
|
||||||
facingZ = motion.chargeEnd[1] - motion.chargeStart[1];
|
facingZ = motion.chargeEnd[1] - motion.chargeStart[1];
|
||||||
} else if (motion.mode === "returning") {
|
|
||||||
facingX = ARENA_CENTER[0] + motion.formationOffsetX - motion.position[0];
|
|
||||||
facingZ = ARENA_CENTER[1] - motion.position[1];
|
|
||||||
}
|
}
|
||||||
if (Math.hypot(facingX, facingZ) > 0.01) {
|
if (Math.hypot(facingX, facingZ) > 0.01) {
|
||||||
const targetAngle = Math.atan2(facingX, facingZ);
|
const targetAngle = Math.atan2(facingX, facingZ);
|
||||||
@@ -680,224 +680,18 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlternateBossKind = Exclude<ReturnType<typeof useGameStore.getState>["boss"]["id"], "bulldrome">;
|
|
||||||
|
|
||||||
interface AlternateBossConfig {
|
function alternateBossClip(kind: AlternateBossKind, motion: ReturnType<typeof useGameStore.getState>["bossMotion"]) {
|
||||||
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: 1.04,
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const PROTOTYPE_MOVE_MODES = [
|
|
||||||
"skyfall",
|
|
||||||
"mantis_sidestep",
|
|
||||||
"ram_charging",
|
|
||||||
"cinderback_ricochet",
|
|
||||||
"charging",
|
|
||||||
"returning",
|
|
||||||
"sandglass_burrowing",
|
|
||||||
"crab_scuttling",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const PROTOTYPE_ATTACK_MODES = [
|
|
||||||
"tethering",
|
|
||||||
"venom_cast",
|
|
||||||
"breath_telegraph",
|
|
||||||
"breath_sweeping",
|
|
||||||
"mantis_line_telegraph",
|
|
||||||
"mantis_cross_telegraph",
|
|
||||||
"ram_charge_telegraph",
|
|
||||||
"ram_quake",
|
|
||||||
"ram_shatter",
|
|
||||||
"cinderback_curl",
|
|
||||||
"cinderback_slam",
|
|
||||||
"ghost_soul_cross",
|
|
||||||
"ghost_soul_cross_followup",
|
|
||||||
"ghost_haunting",
|
|
||||||
"golem_shockwave",
|
|
||||||
"golem_crownfall",
|
|
||||||
"telegraph",
|
|
||||||
"stacking",
|
|
||||||
"pouncing",
|
|
||||||
"sandglass_burrow_telegraph",
|
|
||||||
"sandglass_eruption",
|
|
||||||
"sandglass_hourglass",
|
|
||||||
"crab_scuttle_telegraph",
|
|
||||||
"crab_tidal_burst",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeof useGameStore.getState>["bossMotion"]["mode"]) {
|
|
||||||
const config = ALTERNATE_BOSS_CONFIG[kind];
|
const config = ALTERNATE_BOSS_CONFIG[kind];
|
||||||
if (config.prototype) {
|
return config[bossAnimationCue(motion)];
|
||||||
if ((PROTOTYPE_MOVE_MODES as readonly string[]).includes(motionMode)) return config.move;
|
|
||||||
if ((PROTOTYPE_ATTACK_MODES as readonly string[]).includes(motionMode)) return config.attack;
|
|
||||||
return config.idle;
|
|
||||||
}
|
|
||||||
if (kind === "sandglass-scorpion") {
|
|
||||||
if (motionMode === "sandglass_burrow_telegraph" || motionMode === "sandglass_burrowing") return config.move;
|
|
||||||
if (motionMode === "sandglass_eruption") return config.attack;
|
|
||||||
if (motionMode === "sandglass_hourglass") return config.special;
|
|
||||||
if (motionMode === "sandglass_recover") return "Stagger";
|
|
||||||
}
|
|
||||||
if (kind === "cragclaw-crab") {
|
|
||||||
if (motionMode === "crab_scuttling") return config.move;
|
|
||||||
if (motionMode === "crab_scuttle_telegraph") return config.attack;
|
|
||||||
if (motionMode === "crab_tidal_burst") return config.special;
|
|
||||||
}
|
|
||||||
if (kind === "mournveil-ghost") {
|
|
||||||
if (motionMode === "ghost_soul_cross" || motionMode === "ghost_soul_cross_followup") return config.attack;
|
|
||||||
if (motionMode === "ghost_haunting") return config.special;
|
|
||||||
}
|
|
||||||
if (kind === "crownshard-golem") {
|
|
||||||
if (motionMode === "golem_shockwave") return config.attack;
|
|
||||||
if (motionMode === "golem_crownfall") return config.special;
|
|
||||||
}
|
|
||||||
if (kind === "crystal-bat-matriarch") {
|
|
||||||
if (motionMode === "golem_shockwave") return config.attack;
|
|
||||||
if (motionMode === "golem_crownfall") return config.special;
|
|
||||||
if (motionMode === "golem_recover") return "Stagger";
|
|
||||||
}
|
|
||||||
if (BOSS_ARCHETYPE_BY_ID[kind] === "web-caster" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
|
|
||||||
return config.idle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
|
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
|
||||||
const config = ALTERNATE_BOSS_CONFIG[kind];
|
const config = ALTERNATE_BOSS_CONFIG[kind];
|
||||||
const archetype = BOSS_ARCHETYPE_BY_ID[kind];
|
const archetype = BOSS_ARCHETYPE_BY_ID[kind];
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
|
const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
|
||||||
|
const motionMode = motion?.mode ?? "holding";
|
||||||
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
|
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
|
||||||
const defeated = bossHp <= 0;
|
const defeated = bossHp <= 0;
|
||||||
const group = useRef<THREE.Group>(null);
|
const group = useRef<THREE.Group>(null);
|
||||||
@@ -915,7 +709,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
|||||||
});
|
});
|
||||||
}, [kind, model]);
|
}, [kind, model]);
|
||||||
|
|
||||||
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motionMode);
|
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motion ?? useGameStore.getState().bossMotion);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const next = actions[clipName];
|
const next = actions[clipName];
|
||||||
@@ -944,10 +738,11 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
|||||||
if (!current) return;
|
if (!current) return;
|
||||||
const motion = current.motion;
|
const motion = current.motion;
|
||||||
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
|
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
|
||||||
const burrowed = archetype === "burrower" && motion.mode === "sandglass_burrowing";
|
const burrowed = archetype === "burrower" && motion.activeMechanicId === "burrow-rush" && motion.mode === "charging";
|
||||||
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],
|
||||||
@@ -955,14 +750,13 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
|||||||
);
|
);
|
||||||
if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
|
if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
|
||||||
targetAngle = motion.breathAngle;
|
targetAngle = motion.breathAngle;
|
||||||
} else if (archetype === "duelist" && (
|
} else if (
|
||||||
motion.mode === "mantis_sidestep"
|
motion.mode === "mantis_line_telegraph"
|
||||||
|| motion.mode === "mantis_line_telegraph"
|
|
||||||
|| motion.mode === "mantis_cross_telegraph"
|
|| motion.mode === "mantis_cross_telegraph"
|
||||||
)) {
|
) {
|
||||||
const target = state.partyPositions[motion.chargeTargetId];
|
const target = state.partyPositions[motion.chargeTargetId];
|
||||||
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
|
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
|
||||||
} else if (["ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"].includes(motion.mode)) {
|
} else if (motion.mode === "telegraph" || motion.mode === "charging") {
|
||||||
targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]);
|
targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]);
|
||||||
}
|
}
|
||||||
const difference = Math.atan2(
|
const difference = Math.atan2(
|
||||||
|
|||||||
@@ -81,7 +81,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 +108,7 @@ 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 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,6 +116,9 @@ 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 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
|
||||||
@@ -122,7 +126,7 @@ function PhaseOverlay() {
|
|||||||
? `${bossNames} Broken`
|
? `${bossNames} Broken`
|
||||||
: "Party Broken";
|
: "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"
|
? "Encounter Complete"
|
||||||
: "Encounter Failed";
|
: "Encounter Failed";
|
||||||
@@ -193,7 +197,7 @@ 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>{runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{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>1–6</b> Cast</div>
|
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>1–6</b> Cast</div>
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { useRef, type ComponentType } from "react";
|
import { Html } from "@react-three/drei";
|
||||||
|
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 } from "../../game/bossMechanics";
|
import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool";
|
||||||
import { MEMORY_SEQUENCE, MEMORY_SYMBOLS } from "../../game/bosses/mechanicPool";
|
import { angleTo } from "../../game/geometry";
|
||||||
import { SKY_SWEEPER_BREATH } from "../../game/bosses/skySweeper";
|
|
||||||
import { useGameStore } from "../../game/store";
|
import { useGameStore } from "../../game/store";
|
||||||
import type { 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);
|
||||||
const EMPTY_HAZARDS: never[] = [];
|
const EMPTY_HAZARDS: never[] = [];
|
||||||
const EMPTY_SLASH_LANES: never[] = [];
|
const EMPTY_SLASH_LANES: never[] = [];
|
||||||
const EMPTY_POOL_TELEGRAPHS: never[] = [];
|
const EMPTY_POOL_TELEGRAPHS: never[] = [];
|
||||||
const ACTIVE_LANE_MODES = new Set(["mantis_recover", "ram_charging", "ram_recover", "cinderback_ricochet", "cinderback_recover", "sandglass_burrowing", "sandglass_recover", "crab_scuttling", "crab_recover", "ghost_recover"]);
|
const ACTIVE_LANE_MODES = new Set<BossMotionMode>(["charging"]);
|
||||||
const DANGER_WARNING_COLOR = "#ff3b30";
|
const DANGER_WARNING_COLOR = "#ff3b30";
|
||||||
const DANGER_ACTIVE_COLOR = "#d4142a";
|
const DANGER_ACTIVE_COLOR = "#d4142a";
|
||||||
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
|
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
|
||||||
@@ -27,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);
|
||||||
@@ -290,7 +370,7 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
|
|||||||
return <>{hazards.map((hazard) => <CircleHazardIndicator key={hazard.id} hazardId={hazard.id} bossIndex={bossIndex} />)}</>;
|
return <>{hazards.map((hazard) => <CircleHazardIndicator key={hazard.id} hazardId={hazard.id} bossIndex={bossIndex} />)}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tall gold ward plus a lightweight spectral pursuer for Mournveil's healer run. */
|
/** Tall gold ward plus a lightweight spectral pursuer for the shared Soul Siphon mechanic. */
|
||||||
export function SoulSiphonIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
export function SoulSiphonIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
const motion = useGameStore((state) => motionAt(state, bossIndex));
|
const motion = useGameStore((state) => motionAt(state, bossIndex));
|
||||||
@@ -392,12 +472,49 @@ function MemorySymbolMark({ symbol, size = 1, opacity = 1 }: { symbol: MemorySym
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MemoryTileIndicator({ tile, inputActive }: { tile: MemoryTile; inputActive: boolean }) {
|
const MEMORY_SYMBOL_GLYPHS: Record<MemorySymbolId, string> = {
|
||||||
|
triangle: "△",
|
||||||
|
cross: "+",
|
||||||
|
circle: "○",
|
||||||
|
square: "◇",
|
||||||
|
};
|
||||||
|
|
||||||
|
function MemoryTileIndicator({ tile, inputActive, completed }: { tile: MemoryTile; inputActive: boolean; completed: boolean }) {
|
||||||
const color = MEMORY_SYMBOLS[tile.symbol].color;
|
const color = MEMORY_SYMBOLS[tile.symbol].color;
|
||||||
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
|
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
|
||||||
const gridOffsets = [-0.48, 0, 0.48] as const;
|
const gridOffsets = [-0.48, 0, 0.48] as const;
|
||||||
|
const group = useRef<THREE.Group>(null);
|
||||||
|
const fade = useRef(1);
|
||||||
|
const materials = useRef<{ material: THREE.MeshBasicMaterial; baseOpacity: number }[]>([]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const nextMaterials: { material: THREE.MeshBasicMaterial; baseOpacity: number }[] = [];
|
||||||
|
group.current?.traverse((child) => {
|
||||||
|
if (!(child instanceof THREE.Mesh)) return;
|
||||||
|
const childMaterials = Array.isArray(child.material) ? child.material : [child.material];
|
||||||
|
for (const material of childMaterials) {
|
||||||
|
if (material instanceof THREE.MeshBasicMaterial) {
|
||||||
|
nextMaterials.push({ material, baseOpacity: material.opacity });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
materials.current = nextMaterials;
|
||||||
|
}, [inputActive]);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (!completed && fade.current >= 0.999) return;
|
||||||
|
if (completed && fade.current <= 0.01) {
|
||||||
|
fade.current = 0;
|
||||||
|
if (group.current) group.current.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (group.current) group.current.visible = true;
|
||||||
|
fade.current = THREE.MathUtils.damp(fade.current, completed ? 0 : 1, 18, delta);
|
||||||
|
for (const entry of materials.current) entry.material.opacity = entry.baseOpacity * fade.current;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={[tile.center[0], 0.075, tile.center[1]]}>
|
<group ref={group} position={[tile.center[0], 0.075, tile.center[1]]}>
|
||||||
<mesh>
|
<mesh>
|
||||||
<boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} />
|
<boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} />
|
||||||
<meshBasicMaterial color={color} transparent opacity={inputActive ? 0.28 : 0.16} depthWrite={false} />
|
<meshBasicMaterial color={color} transparent opacity={inputActive ? 0.28 : 0.16} depthWrite={false} />
|
||||||
@@ -440,21 +557,60 @@ function MemorySequenceIndicator({ telegraph, bossPosition, time }: { telegraph:
|
|||||||
);
|
);
|
||||||
const flashSymbol = telegraph.sequence[flashIndex];
|
const flashSymbol = telegraph.sequence[flashIndex];
|
||||||
const source = bossPosition ?? telegraph.center;
|
const source = bossPosition ?? telegraph.center;
|
||||||
|
const completedCount = showingSequence ? 0 : telegraph.inputIndex ?? 0;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{telegraph.tiles.map((tile) => <MemoryTileIndicator key={tile.symbol} tile={tile} inputActive={!showingSequence} />)}
|
{telegraph.tiles.map((tile) => {
|
||||||
|
const sequenceIndex = telegraph.sequence!.indexOf(tile.symbol);
|
||||||
|
return (
|
||||||
|
<MemoryTileIndicator
|
||||||
|
key={tile.symbol}
|
||||||
|
tile={tile}
|
||||||
|
inputActive={!showingSequence}
|
||||||
|
completed={sequenceIndex >= 0 && sequenceIndex < completedCount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{showingSequence && (
|
{showingSequence && (
|
||||||
<group position={[source[0], 2.5, source[1]]}>
|
<Html position={[source[0], 3.1, source[1] + 0.3]} center zIndexRange={[30, 20]} style={{ pointerEvents: "none" }}>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<div style={{ display: "grid", justifyItems: "center", gap: 7 }}>
|
||||||
<circleGeometry args={[0.88, 32]} />
|
<div
|
||||||
<meshBasicMaterial color="#111827" transparent opacity={0.9} depthWrite={false} />
|
style={{
|
||||||
</mesh>
|
width: 64,
|
||||||
<mesh position={[0, 0.015, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
height: 64,
|
||||||
<ringGeometry args={[0.8, 0.9, 32]} />
|
display: "grid",
|
||||||
<meshBasicMaterial color={MEMORY_SYMBOLS[flashSymbol].color} transparent opacity={1} depthWrite={false} />
|
placeItems: "center",
|
||||||
</mesh>
|
borderRadius: "50%",
|
||||||
<MemorySymbolMark symbol={flashSymbol} size={1.05} />
|
border: `4px solid ${MEMORY_SYMBOLS[flashSymbol].color}`,
|
||||||
</group>
|
background: "rgba(17, 24, 39, 0.96)",
|
||||||
|
boxShadow: "0 0 0 4px rgba(216, 184, 92, 0.92), 0 0 18px rgba(255, 244, 199, 0.8)",
|
||||||
|
color: MEMORY_SYMBOLS[flashSymbol].color,
|
||||||
|
fontSize: 47,
|
||||||
|
fontWeight: 900,
|
||||||
|
lineHeight: 1,
|
||||||
|
textShadow: "0 0 8px currentColor",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{MEMORY_SYMBOL_GLYPHS[flashSymbol]}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
{telegraph.sequence.map((_, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
width: index === flashIndex ? 9 : 7,
|
||||||
|
height: index === flashIndex ? 9 : 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: index === flashIndex
|
||||||
|
? MEMORY_SYMBOLS[flashSymbol].color
|
||||||
|
: index < flashIndex ? "#fff4c7" : "#64748b",
|
||||||
|
boxShadow: index === flashIndex ? "0 0 7px currentColor" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -569,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(); }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("unseen");
|
||||||
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: "1–4 hunters · five-round PVE trial",
|
||||||
|
title: "Rogue Trials",
|
||||||
|
description: "Build through four randomized dual-boss rounds, then face three bosses together in a final trial.",
|
||||||
|
detail: "Round 5 trio always uses bosses unseen during that run",
|
||||||
|
status: "Playable now",
|
||||||
},
|
},
|
||||||
dungeons: {
|
dungeons: {
|
||||||
eyebrow: "1–4 hunters · chosen encounter",
|
eyebrow: "1–4 hunters · chosen encounter",
|
||||||
@@ -131,6 +138,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
|
|||||||
alliesSaved: 0,
|
alliesSaved: 0,
|
||||||
healingDone: 0,
|
healingDone: 0,
|
||||||
bossKills: {},
|
bossKills: {},
|
||||||
|
highestRoguelikeRound: 0,
|
||||||
},
|
},
|
||||||
materials: [] as MaterialStack[],
|
materials: [] as MaterialStack[],
|
||||||
collectionLog: createEmptyCollectionLog(),
|
collectionLog: createEmptyCollectionLog(),
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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";
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onlineRepository = new OnlineRepository();
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { SaveRepository, type StorageAdapter } from "./saveRepository";
|
import { SaveRepository, type StorageAdapter } from "./saveRepository";
|
||||||
import { groupDrop } from "../game/progression/loot";
|
import { groupDrop } from "../game/progression/loot";
|
||||||
|
import { RUN_BUFF_ORDER } from "../game/roguelike";
|
||||||
|
|
||||||
function memoryStorage(): StorageAdapter {
|
function memoryStorage(): StorageAdapter {
|
||||||
const data = new Map<string, string>();
|
const data = new Map<string, string>();
|
||||||
@@ -15,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");
|
||||||
@@ -33,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", () => {
|
||||||
@@ -82,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);
|
||||||
@@ -101,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, {
|
||||||
@@ -112,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 });
|
||||||
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");
|
||||||
@@ -156,7 +126,7 @@ describe("SaveRepository", () => {
|
|||||||
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 });
|
||||||
@@ -169,16 +139,29 @@ describe("SaveRepository", () => {
|
|||||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||||
const created = repository.create(1, "Infused");
|
const created = repository.create(1, "Infused");
|
||||||
created.gearProgress.priest.infusionAbilityId = "priest-sanctuary";
|
created.gearProgress.priest.infusionAbilityId = "priest-sanctuary";
|
||||||
created.gearProgress.priest.passiveInfusionId = "restoring-grace";
|
created.gearProgress.priest.passiveInfusionId = "restoring-grace" as never;
|
||||||
|
created.gearProgress.druid.passiveInfusionId = "mend-echo";
|
||||||
created.gearProgress.brann.infusionAbilityId = "removed-infusion";
|
created.gearProgress.brann.infusionAbilityId = "removed-infusion";
|
||||||
created.gearProgress.brann.passiveInfusionId = "deep-wells";
|
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).toBe("restoring-grace");
|
expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull();
|
||||||
|
expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo");
|
||||||
expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull();
|
expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull();
|
||||||
expect(migrated.gearProgress.brann.passiveInfusionId).toBeNull();
|
expect(migrated.gearProgress.brann.passiveInfusionId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists every new roguelike buff as a healer passive infusion", () => {
|
||||||
|
for (const passiveId of RUN_BUFF_ORDER) {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||||
|
const created = repository.create(1, "Infused");
|
||||||
|
created.gearProgress.priest.passiveInfusionId = passiveId;
|
||||||
|
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
||||||
|
expect(repository.listLocal()[0].local?.gearProgress.priest.passiveInfusionId).toBe(passiveId);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,7 @@ 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)),
|
||||||
},
|
},
|
||||||
materials: normalizeMaterials(candidate.materials, collectionLog),
|
materials: normalizeMaterials(candidate.materials, collectionLog),
|
||||||
collectionLog,
|
collectionLog,
|
||||||
@@ -181,10 +181,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 +222,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 {
|
||||||
|
|||||||
+146
-34
@@ -2,20 +2,34 @@ 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 { BossId, HealerClassId, InventoryItem } from "../game/types";
|
import type { AbilityId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types";
|
||||||
|
import { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike";
|
||||||
import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
|
import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
|
||||||
import {
|
import {
|
||||||
equipActiveInfusion,
|
equipActiveInfusion,
|
||||||
equipPassiveInfusion,
|
equipPassiveInfusion,
|
||||||
infusionsForOwner,
|
infusionsForOwner,
|
||||||
} from "../game/progression/infusions";
|
} from "../game/progression/infusions";
|
||||||
import type { RunBuffId } from "../game/types";
|
|
||||||
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
|
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
|
||||||
|
import { 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;
|
||||||
@@ -59,9 +89,12 @@ export interface FrontendState {
|
|||||||
selectedGearSlotId: GearSlotId;
|
selectedGearSlotId: GearSlotId;
|
||||||
gearWorkshopMode: "upgrade" | "infusion";
|
gearWorkshopMode: "upgrade" | "infusion";
|
||||||
selectedInfusionId: string;
|
selectedInfusionId: string;
|
||||||
|
selectedPassiveAbilityId: AbilityId;
|
||||||
|
selectedPassiveInfusionId: RunBuffId;
|
||||||
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;
|
||||||
@@ -72,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;
|
||||||
@@ -81,6 +114,8 @@ export interface FrontendState {
|
|||||||
selectGearSlot: (slotId: GearSlotId) => void;
|
selectGearSlot: (slotId: GearSlotId) => void;
|
||||||
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
|
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
|
||||||
selectInfusion: (infusionId: string) => void;
|
selectInfusion: (infusionId: string) => void;
|
||||||
|
selectPassiveAbility: (abilityId: AbilityId) => void;
|
||||||
|
selectPassiveInfusion: (passiveId: RunBuffId) => void;
|
||||||
upgradeSelectedGear: () => boolean;
|
upgradeSelectedGear: () => boolean;
|
||||||
equipSelectedInfusion: () => boolean;
|
equipSelectedInfusion: () => boolean;
|
||||||
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
|
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
|
||||||
@@ -89,6 +124,7 @@ 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;
|
||||||
clearRecentRewards: () => void;
|
clearRecentRewards: () => void;
|
||||||
clearNotice: () => void;
|
clearNotice: () => void;
|
||||||
}
|
}
|
||||||
@@ -100,7 +136,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",
|
||||||
@@ -110,18 +146,38 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
selectedGearSlotId: "weapon",
|
selectedGearSlotId: "weapon",
|
||||||
gearWorkshopMode: "upgrade",
|
gearWorkshopMode: "upgrade",
|
||||||
selectedInfusionId: infusionsForOwner("priest")[0].id,
|
selectedInfusionId: infusionsForOwner("priest")[0].id,
|
||||||
|
selectedPassiveAbilityId: "mend",
|
||||||
|
selectedPassiveInfusionId: "mend-echo",
|
||||||
recentRewards: [],
|
recentRewards: [],
|
||||||
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);
|
||||||
@@ -129,11 +185,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) => {
|
||||||
@@ -143,18 +208,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.`,
|
||||||
}));
|
}));
|
||||||
@@ -162,19 +227,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: "" }),
|
||||||
@@ -187,6 +269,15 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
|||||||
selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }),
|
selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }),
|
||||||
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
|
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
|
||||||
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }),
|
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }),
|
||||||
|
selectPassiveAbility: (selectedPassiveAbilityId) => {
|
||||||
|
const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilityId === selectedPassiveAbilityId) ?? "mend-echo";
|
||||||
|
set({ selectedPassiveAbilityId, selectedPassiveInfusionId, notice: "" });
|
||||||
|
},
|
||||||
|
selectPassiveInfusion: (selectedPassiveInfusionId) => set({
|
||||||
|
selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilityId,
|
||||||
|
selectedPassiveInfusionId,
|
||||||
|
notice: "",
|
||||||
|
}),
|
||||||
upgradeSelectedGear: () => {
|
upgradeSelectedGear: () => {
|
||||||
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
|
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
|
||||||
if (!activeSlotId) return false;
|
if (!activeSlotId) return false;
|
||||||
@@ -203,7 +294,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: () => {
|
||||||
@@ -223,7 +314,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) => {
|
||||||
@@ -242,7 +333,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) => {
|
||||||
@@ -251,7 +342,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.`,
|
||||||
@@ -267,7 +358,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 };
|
||||||
@@ -278,10 +369,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) => {
|
||||||
@@ -296,17 +387,31 @@ 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] : 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) }));
|
||||||
|
},
|
||||||
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"
|
||||||
@@ -326,6 +431,8 @@ export type FrontendSnapshot = Omit<FrontendState,
|
|||||||
| "selectGearSlot"
|
| "selectGearSlot"
|
||||||
| "selectGearWorkshopMode"
|
| "selectGearWorkshopMode"
|
||||||
| "selectInfusion"
|
| "selectInfusion"
|
||||||
|
| "selectPassiveAbility"
|
||||||
|
| "selectPassiveInfusion"
|
||||||
| "upgradeSelectedGear"
|
| "upgradeSelectedGear"
|
||||||
| "equipSelectedInfusion"
|
| "equipSelectedInfusion"
|
||||||
| "equipPassiveInfusion"
|
| "equipPassiveInfusion"
|
||||||
@@ -334,12 +441,14 @@ export type FrontendSnapshot = Omit<FrontendState,
|
|||||||
| "updateSetting"
|
| "updateSetting"
|
||||||
| "touchActiveSave"
|
| "touchActiveSave"
|
||||||
| "recordBossVictory"
|
| "recordBossVictory"
|
||||||
|
| "recordRoguelikeDefeat"
|
||||||
| "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,
|
||||||
@@ -359,6 +468,8 @@ export function getFrontendSnapshot(): FrontendSnapshot {
|
|||||||
selectGearSlot: _selectGearSlot,
|
selectGearSlot: _selectGearSlot,
|
||||||
selectGearWorkshopMode: _selectGearWorkshopMode,
|
selectGearWorkshopMode: _selectGearWorkshopMode,
|
||||||
selectInfusion: _selectInfusion,
|
selectInfusion: _selectInfusion,
|
||||||
|
selectPassiveAbility: _selectPassiveAbility,
|
||||||
|
selectPassiveInfusion: _selectPassiveInfusion,
|
||||||
upgradeSelectedGear: _upgradeSelectedGear,
|
upgradeSelectedGear: _upgradeSelectedGear,
|
||||||
equipSelectedInfusion: _equipSelectedInfusion,
|
equipSelectedInfusion: _equipSelectedInfusion,
|
||||||
equipPassiveInfusion: _equipPassiveInfusion,
|
equipPassiveInfusion: _equipPassiveInfusion,
|
||||||
@@ -367,6 +478,7 @@ export function getFrontendSnapshot(): FrontendSnapshot {
|
|||||||
updateSetting: _updateSetting,
|
updateSetting: _updateSetting,
|
||||||
touchActiveSave: _touchActiveSave,
|
touchActiveSave: _touchActiveSave,
|
||||||
recordBossVictory: _recordBossVictory,
|
recordBossVictory: _recordBossVictory,
|
||||||
|
recordRoguelikeDefeat: _recordRoguelikeDefeat,
|
||||||
clearRecentRewards: _clearRecentRewards,
|
clearRecentRewards: _clearRecentRewards,
|
||||||
clearNotice: _clearNotice,
|
clearNotice: _clearNotice,
|
||||||
...snapshot
|
...snapshot
|
||||||
|
|||||||
@@ -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,7 @@ export interface HunterStats {
|
|||||||
alliesSaved: number;
|
alliesSaved: number;
|
||||||
healingDone: number;
|
healingDone: number;
|
||||||
bossKills: Record<string, number>;
|
bossKills: Record<string, number>;
|
||||||
|
highestRoguelikeRound: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealerProgress {
|
export interface HealerProgress {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
|
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
|
||||||
import { createBossMotionState, createBossState } from "./bossMechanics";
|
import { createBossMotionState, createBossState } from "./bossMechanics";
|
||||||
|
import { BOSS_MECHANIC_POOL, BOSS_MECHANIC_REGISTRY, bossMechanicName } from "./bosses/mechanicPool";
|
||||||
|
|
||||||
describe("boss catalog", () => {
|
describe("boss catalog", () => {
|
||||||
it("derives the available roster from every catalog definition", () => {
|
it("derives the available roster from every catalog definition", () => {
|
||||||
@@ -42,4 +43,21 @@ describe("boss catalog", () => {
|
|||||||
expect(state.hp).toBe(state.maxHp);
|
expect(state.hp).toBe(state.maxHp);
|
||||||
expect(motion.bossId).toBe(bossId);
|
expect(motion.bossId).toBe(bossId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves every boss loadout through the canonical mechanic registry", () => {
|
||||||
|
const assigned = new Set(Object.values(BOSS_DEFINITIONS).flatMap((boss) => boss.mechanicIds));
|
||||||
|
expect(assigned).toEqual(new Set(Object.keys(BOSS_MECHANIC_REGISTRY)));
|
||||||
|
expect(Object.keys(BOSS_MECHANIC_REGISTRY)).toEqual(BOSS_MECHANIC_POOL.map((mechanic) => mechanic.id));
|
||||||
|
for (const boss of Object.values(BOSS_DEFINITIONS)) {
|
||||||
|
expect(boss.mechanicIds).toContain("basic-melee");
|
||||||
|
for (const mechanicId of boss.mechanicIds) {
|
||||||
|
expect(BOSS_MECHANIC_REGISTRY[mechanicId].name).toBe(bossMechanicName(mechanicId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every boss a distinct mechanic kit", () => {
|
||||||
|
const kits = Object.values(BOSS_DEFINITIONS).map((boss) => boss.mechanicIds.join(","));
|
||||||
|
expect(new Set(kits)).toHaveLength(AVAILABLE_BOSS_IDS.length);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+36
-31
@@ -1,4 +1,5 @@
|
|||||||
import type { BossId } from "./types";
|
import { bossMechanicName } from "./bosses/mechanicPool";
|
||||||
|
import type { BossId, BossMechanicId } from "./types";
|
||||||
|
|
||||||
export type BossArchetype =
|
export type BossArchetype =
|
||||||
| "bull"
|
| "bull"
|
||||||
@@ -46,7 +47,7 @@ export interface BossDefinition {
|
|||||||
failure: string;
|
failure: string;
|
||||||
mapTitle: string;
|
mapTitle: string;
|
||||||
mapCopy: string;
|
mapCopy: string;
|
||||||
mechanics: readonly [string, string, ...string[]];
|
mechanicIds: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]];
|
||||||
maxHp: number;
|
maxHp: number;
|
||||||
archetype: BossArchetype;
|
archetype: BossArchetype;
|
||||||
groupId: BossGroupId;
|
groupId: BossGroupId;
|
||||||
@@ -58,16 +59,20 @@ interface BossSeed {
|
|||||||
icon: string;
|
icon: string;
|
||||||
accent: string;
|
accent: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
mechanics: readonly [string, string, ...string[]];
|
mechanicIds: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]];
|
||||||
maxHp: number;
|
maxHp: number;
|
||||||
archetype: BossArchetype;
|
archetype: BossArchetype;
|
||||||
}
|
}
|
||||||
|
|
||||||
function boss(id: BossId, index: number, seed: BossSeed): BossDefinition {
|
function boss(id: BossId, index: number, seed: BossSeed): BossDefinition {
|
||||||
const [first, second] = seed.mechanics;
|
const [firstId, secondId] = seed.mechanicIds;
|
||||||
|
const first = bossMechanicName(firstId);
|
||||||
|
const second = bossMechanicName(secondId);
|
||||||
|
const mechanicIds = [...seed.mechanicIds, "basic-melee"] as BossDefinition["mechanicIds"];
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
...seed,
|
...seed,
|
||||||
|
mechanicIds,
|
||||||
groupId: BOSS_GROUP_BY_BOSS_ID[id],
|
groupId: BOSS_GROUP_BY_BOSS_ID[id],
|
||||||
trial: `Trial ${String(index + 1).padStart(2, "0")} · ${seed.title}`,
|
trial: `Trial ${String(index + 1).padStart(2, "0")} · ${seed.title}`,
|
||||||
briefing: `Read ${first}, then preserve open ground for ${second}.`,
|
briefing: `Read ${first}, then preserve open ground for ${second}.`,
|
||||||
@@ -80,111 +85,111 @@ function boss(id: BossId, index: number, seed: BossSeed): BossDefinition {
|
|||||||
const BOSS_SEEDS: Record<BossId, BossSeed> = {
|
const BOSS_SEEDS: Record<BossId, BossSeed> = {
|
||||||
bulldrome: {
|
bulldrome: {
|
||||||
name: "Bulldrome", title: "Hall of the Cinder Bull", icon: "♜", accent: "#e2744e",
|
name: "Bulldrome", title: "Hall of the Cinder Bull", icon: "♜", accent: "#e2744e",
|
||||||
summary: "Charges marked lanes and crushes grouped targets.", mechanics: ["Bull Charge", "Crushing Pounce"], maxHp: 500, archetype: "bull",
|
summary: "Charges marked lanes and crushes grouped targets.", mechanicIds: ["bull-charge", "crushing-pounce", "cinder-nova", "ember-brand"], maxHp: 500, archetype: "bull",
|
||||||
},
|
},
|
||||||
"sandglass-scorpion": {
|
"sandglass-scorpion": {
|
||||||
name: "Sandglass Scorpion", title: "The Sunken Hour", icon: "⌛", accent: "#e9b94f",
|
name: "Sandglass Scorpion", title: "The Sunken Hour", icon: "⌛", accent: "#e9b94f",
|
||||||
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.", mechanics: ["Burrow Rush", "Hourglass Eruption"], maxHp: 515, archetype: "burrower",
|
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.", mechanicIds: ["burrow-rush", "hourglass-eruption", "memory-sequence"], maxHp: 515, archetype: "burrower",
|
||||||
},
|
},
|
||||||
"cragclaw-crab": {
|
"cragclaw-crab": {
|
||||||
name: "Cragclaw", title: "The Drowned Breakwater", icon: "♋", accent: "#49c7d4",
|
name: "Cragclaw", title: "The Drowned Breakwater", icon: "♋", accent: "#49c7d4",
|
||||||
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.", mechanics: ["Sidewinder Rush", "Crushing Tide"], maxHp: 505, archetype: "crab",
|
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.", mechanicIds: ["sidewinder-rush", "crushing-tide", "aetheric-soak"], maxHp: 505, archetype: "crab",
|
||||||
},
|
},
|
||||||
"mournveil-ghost": {
|
"mournveil-ghost": {
|
||||||
name: "Mournveil", title: "The Silent Reliquary", icon: "◉", accent: "#9d72ff",
|
name: "Mournveil", title: "The Silent Reliquary", icon: "◉", accent: "#9d72ff",
|
||||||
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.", mechanics: ["Soul Scissors", "Haunting Rifts"], maxHp: 505, archetype: "ghost",
|
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.", mechanicIds: ["vine-scissors", "haunting-rifts", "soul-siphon"], maxHp: 505, archetype: "ghost",
|
||||||
},
|
},
|
||||||
"crownshard-golem": {
|
"crownshard-golem": {
|
||||||
name: "Crownshard Golem", title: "The Broken Coronation", icon: "♛", accent: "#e0bd45",
|
name: "Crownshard Golem", title: "The Broken Coronation", icon: "♛", accent: "#e0bd45",
|
||||||
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.", mechanics: ["Royal Shockwave", "Crownfall"], maxHp: 500, archetype: "golem",
|
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.", mechanicIds: ["tri-burst", "ultimate-skyfall", "aetheric-soak"], maxHp: 500, archetype: "golem",
|
||||||
},
|
},
|
||||||
"crystal-bat-matriarch": {
|
"crystal-bat-matriarch": {
|
||||||
name: "Crystal Bat Matriarch", title: "The Prism Echo", icon: "◈", accent: "#8eeaff",
|
name: "Crystal Bat Matriarch", title: "The Prism Echo", icon: "◈", accent: "#8eeaff",
|
||||||
summary: "Sonic rings force precise spacing while orbiting mirror shards fracture safe ground.", mechanics: ["Sonic Ring", "Mirror Shards"], maxHp: 480, archetype: "golem",
|
summary: "Sonic rings force precise spacing while orbiting mirror shards fracture safe ground.", mechanicIds: ["tri-burst", "prism-beam", "memory-sequence"], maxHp: 480, archetype: "golem",
|
||||||
},
|
},
|
||||||
"stormwool-alpaca": {
|
"stormwool-alpaca": {
|
||||||
name: "Stormwool", title: "The Thunder Fleece", icon: "ϟ", accent: "#8fc7ff",
|
name: "Stormwool", title: "The Thunder Fleece", icon: "ϟ", accent: "#8fc7ff",
|
||||||
summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanics: ["Storm Charge", "Cloudburst Pounce"], maxHp: 480, archetype: "bull",
|
summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanicIds: ["bull-charge", "crushing-pounce", "stormfall"], maxHp: 480, archetype: "bull",
|
||||||
},
|
},
|
||||||
"cluckhorn-colossus": {
|
"cluckhorn-colossus": {
|
||||||
name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d",
|
name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d",
|
||||||
summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanics: ["Roost Rush", "Shellburst"], maxHp: 475, archetype: "crab",
|
summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanicIds: ["sidewinder-rush", "crushing-tide", "meteor-spread"], maxHp: 475, archetype: "crab",
|
||||||
},
|
},
|
||||||
"ashwing-demon": {
|
"ashwing-demon": {
|
||||||
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d",
|
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d",
|
||||||
summary: "Carves crossing fire lanes and opens persistent ember rifts.", mechanics: ["Ash Scissors", "Cinder Rifts"], maxHp: 515, archetype: "ghost",
|
summary: "Carves crossing fire lanes and opens persistent ember rifts.", mechanicIds: ["vine-scissors", "haunting-rifts", "cinder-nova"], maxHp: 515, archetype: "ghost",
|
||||||
},
|
},
|
||||||
"riftclaw-demon": {
|
"riftclaw-demon": {
|
||||||
name: "Riftclaw", title: "The Broken Duel", icon: "⚔", accent: "#d45cff",
|
name: "Riftclaw", title: "The Broken Duel", icon: "⚔", accent: "#d45cff",
|
||||||
summary: "Sidesteps around the tank before cutting single and crossed void lanes.", mechanics: ["Rift Blade", "Abyss Cross"], maxHp: 495, archetype: "duelist",
|
summary: "Sidesteps around the tank before cutting single and crossed void lanes.", mechanicIds: ["elemental-beam", "guardian-cross", "soul-siphon"], maxHp: 495, archetype: "duelist",
|
||||||
},
|
},
|
||||||
"tempestscale-dragon": {
|
"tempestscale-dragon": {
|
||||||
name: "Tempestscale", title: "The Living Storm", icon: "☈", accent: "#5fc8e8",
|
name: "Tempestscale", title: "The Living Storm", icon: "☈", accent: "#5fc8e8",
|
||||||
summary: "Sweeps the arena with storm breath before marking allies for sky strikes.", mechanics: ["Tempest Breath", "Stormfall"], maxHp: 500, archetype: "sky-sweeper",
|
summary: "Sweeps the arena with storm breath before marking allies for sky strikes.", mechanicIds: ["storm-breath", "stormfall", "prism-beam"], maxHp: 500, archetype: "sky-sweeper",
|
||||||
},
|
},
|
||||||
emberfox: {
|
emberfox: {
|
||||||
name: "Emberfox", title: "The Burning Trail", icon: "✦", accent: "#ff7b45",
|
name: "Emberfox", title: "The Burning Trail", icon: "✦", accent: "#ff7b45",
|
||||||
summary: "Ricochets across the arena and leaves fire at every landing.", mechanics: ["Foxfire Rush", "Ember Pounce"], maxHp: 465, archetype: "ricochet",
|
summary: "Ricochets across the arena and leaves fire at every landing.", mechanicIds: ["ricochet-rush", "meteor-slam", "ember-brand"], maxHp: 465, archetype: "ricochet",
|
||||||
},
|
},
|
||||||
"mirelord-frog": {
|
"mirelord-frog": {
|
||||||
name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b",
|
name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b",
|
||||||
summary: "Dives below the mire before erupting through timed bog zones.", mechanics: ["Mire Dive", "Bogglass Eruption"], maxHp: 490, archetype: "burrower",
|
summary: "Dives below the mire before erupting through timed bog zones.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower",
|
||||||
},
|
},
|
||||||
"stonebreaker-giant": {
|
"stonebreaker-giant": {
|
||||||
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563",
|
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563",
|
||||||
summary: "Sends quake bands across the floor and rains boulders on spread allies.", mechanics: ["Crag Shockwave", "Boulderfall"], maxHp: 500, archetype: "golem",
|
summary: "Sends quake bands across the floor and rains boulders on spread allies.", mechanicIds: ["tri-burst", "ruin-quake", "meteor-spread"], maxHp: 500, archetype: "golem",
|
||||||
},
|
},
|
||||||
"glub-sovereign": {
|
"glub-sovereign": {
|
||||||
name: "Glub Sovereign", title: "The Binding Ooze", icon: "◌", accent: "#6ce0b8",
|
name: "Glub Sovereign", title: "The Binding Ooze", icon: "◌", accent: "#6ce0b8",
|
||||||
summary: "Links two allies with living slime before seeding toxic pools.", mechanics: ["Ooze Tether", "Caustic Brood"], maxHp: 495, archetype: "web-caster",
|
summary: "Links two allies with living slime before seeding toxic pools.", mechanicIds: ["binding-web", "venom-purge", "hollow-collapse"], maxHp: 495, archetype: "web-caster",
|
||||||
},
|
},
|
||||||
"scrapking-goblin": {
|
"scrapking-goblin": {
|
||||||
name: "Scrapking", title: "The Jagged Throne", icon: "⚒", accent: "#d7a34b",
|
name: "Scrapking", title: "The Jagged Throne", icon: "⚒", accent: "#d7a34b",
|
||||||
summary: "Repositions between attacks and fires improvised blade lanes.", mechanics: ["Scrap Blade", "Junkyard Cross"], maxHp: 485, archetype: "duelist",
|
summary: "Repositions between attacks and fires improvised blade lanes.", mechanicIds: ["elemental-beam", "guardian-cross", "meteor-spread"], maxHp: 485, archetype: "duelist",
|
||||||
},
|
},
|
||||||
"warcaller-orc": {
|
"warcaller-orc": {
|
||||||
name: "Warcaller", title: "The Red Standard", icon: "⚑", accent: "#e4533f",
|
name: "Warcaller", title: "The Red Standard", icon: "⚑", accent: "#e4533f",
|
||||||
summary: "Tracks the party flank before cleaving single and crossed war lanes.", mechanics: ["Warpath Cleave", "Banner Cross"], maxHp: 500, archetype: "duelist",
|
summary: "Tracks the party flank before cleaving single and crossed war lanes.", mechanicIds: ["elemental-beam", "guardian-cross", "aetheric-soak"], maxHp: 500, archetype: "duelist",
|
||||||
},
|
},
|
||||||
"tuskmaw-orc": {
|
"tuskmaw-orc": {
|
||||||
name: "Tuskmaw", title: "The Breaker Below", icon: "◈", accent: "#9eb25d",
|
name: "Tuskmaw", title: "The Breaker Below", icon: "◈", accent: "#9eb25d",
|
||||||
summary: "Rushes laterally and crushes three marked allies beneath tusk bursts.", mechanics: ["Tusk Rush", "Groundbreaker"], maxHp: 525, archetype: "crab",
|
summary: "Rushes laterally and crushes three marked allies beneath tusk bursts.", mechanicIds: ["sidewinder-rush", "crushing-tide", "destruction-pulse"], maxHp: 525, archetype: "crab",
|
||||||
},
|
},
|
||||||
"broodfang-spider": {
|
"broodfang-spider": {
|
||||||
name: "Broodfang", title: "The Silk Tyrant", icon: "✣", accent: "#b56cff",
|
name: "Broodfang", title: "The Silk Tyrant", icon: "✣", accent: "#b56cff",
|
||||||
summary: "Binds paired prey with silk before flooding safe ground with venom.", mechanics: ["Binding Web", "Venom Brood"], maxHp: 535, archetype: "web-caster",
|
summary: "Binds paired prey with silk before flooding safe ground with venom.", mechanicIds: ["binding-web", "venom-purge", "meteor-spread"], maxHp: 535, archetype: "web-caster",
|
||||||
},
|
},
|
||||||
"silkfang-spider": {
|
"silkfang-spider": {
|
||||||
name: "Silkfang", title: "The Gloom Weaver", icon: "✤", accent: "#9d68d8",
|
name: "Silkfang", title: "The Gloom Weaver", icon: "✤", accent: "#9d68d8",
|
||||||
summary: "Snares paired allies before seeding the arena with toxic nests.", mechanics: ["Silk Snare", "Venom Nest"], maxHp: 505, archetype: "web-caster",
|
summary: "Snares paired allies before seeding the arena with toxic nests.", mechanicIds: ["binding-web", "venom-purge", "soul-siphon"], maxHp: 505, archetype: "web-caster",
|
||||||
},
|
},
|
||||||
"thorncrown-stag": {
|
"thorncrown-stag": {
|
||||||
name: "Thorncrown", title: "The Briar Hart", icon: "♧", accent: "#7fc46b",
|
name: "Thorncrown", title: "The Briar Hart", icon: "♧", accent: "#7fc46b",
|
||||||
summary: "Charges through thorn lanes and leaps onto grouped prey.", mechanics: ["Briar Charge", "Crown Pounce"], maxHp: 510, archetype: "bull",
|
summary: "Charges through thorn lanes and leaps onto grouped prey.", mechanicIds: ["bull-charge", "crushing-pounce", "hollow-collapse"], maxHp: 510, archetype: "bull",
|
||||||
},
|
},
|
||||||
"sky-totem": {
|
"sky-totem": {
|
||||||
name: "Sky Totem", title: "The Hollow Idol", icon: "☼", accent: "#69d4d1",
|
name: "Sky Totem", title: "The Hollow Idol", icon: "☼", accent: "#69d4d1",
|
||||||
summary: "Cuts the floor with spirit lanes and anchors hungry wind rifts.", mechanics: ["Spirit Scissors", "Wind Rifts"], maxHp: 505, archetype: "ghost",
|
summary: "Cuts the floor with spirit lanes and anchors hungry wind rifts.", mechanicIds: ["vine-scissors", "haunting-rifts", "prism-beam"], maxHp: 505, archetype: "ghost",
|
||||||
},
|
},
|
||||||
"razorcrest-raptor": {
|
"razorcrest-raptor": {
|
||||||
name: "Razorcrest", title: "The Hunting Circuit", icon: "➳", accent: "#d9c45a",
|
name: "Razorcrest", title: "The Hunting Circuit", icon: "➳", accent: "#d9c45a",
|
||||||
summary: "Rebounds through hunting lanes and tears open impact pools.", mechanics: ["Raptor Rush", "Talon Slam"], maxHp: 500, archetype: "ricochet",
|
summary: "Rebounds through hunting lanes and tears open impact pools.", mechanicIds: ["ricochet-rush", "meteor-slam", "prism-beam"], maxHp: 500, archetype: "ricochet",
|
||||||
},
|
},
|
||||||
"bristlequake-boar": {
|
"bristlequake-boar": {
|
||||||
name: "Bristlequake", title: "The Iron Tusk", icon: "♞", accent: "#d47b45",
|
name: "Bristlequake", title: "The Iron Tusk", icon: "♞", accent: "#d47b45",
|
||||||
summary: "Breaks formation with armored rushes, quakes, and radial fault lines.", mechanics: ["Tusk Charge", "Bristle Quake"], maxHp: 545, archetype: "ram",
|
summary: "Breaks formation with armored rushes, quakes, and radial fault lines.", mechanicIds: ["destruction-rush", "ruin-quake", "destruction-pulse"], maxHp: 545, archetype: "ram",
|
||||||
},
|
},
|
||||||
"moonfang-wolf": {
|
"moonfang-wolf": {
|
||||||
name: "Moonfang", title: "The Silver Pursuit", icon: "☾", accent: "#9db9e5",
|
name: "Moonfang", title: "The Silver Pursuit", icon: "☾", accent: "#9db9e5",
|
||||||
summary: "Ricochets between marked lanes and leaves moonfire at each strike.", mechanics: ["Lunar Rush", "Moonfall"], maxHp: 495, archetype: "ricochet",
|
summary: "Ricochets between marked lanes and leaves moonfire at each strike.", mechanicIds: ["ricochet-rush", "meteor-slam", "soul-siphon"], maxHp: 495, archetype: "ricochet",
|
||||||
},
|
},
|
||||||
"frostmaw-yeti": {
|
"frostmaw-yeti": {
|
||||||
name: "Frostmaw", title: "The White Avalanche", icon: "❄", accent: "#8ed8ef",
|
name: "Frostmaw", title: "The White Avalanche", icon: "❄", accent: "#8ed8ef",
|
||||||
summary: "Scuttles through ice lanes and buries spread allies beneath frost bursts.", mechanics: ["Avalanche Rush", "Frost Crush"], maxHp: 550, archetype: "crab",
|
summary: "Scuttles through ice lanes and buries spread allies beneath frost bursts.", mechanicIds: ["sidewinder-rush", "crushing-tide", "hollow-collapse"], maxHp: 550, archetype: "crab",
|
||||||
},
|
},
|
||||||
"rimeclaw-yeti": {
|
"rimeclaw-yeti": {
|
||||||
name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8",
|
name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8",
|
||||||
summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanics: ["Rime Blade", "Glacier Cross"], maxHp: 500, archetype: "duelist",
|
summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanicIds: ["elemental-beam", "guardian-cross", "memory-sequence"], maxHp: 500, archetype: "duelist",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ describe("boss home positioning", () => {
|
|||||||
it.each(AVAILABLE_BOSS_IDS)("moves %s back toward its center slot while idle", (bossId) => {
|
it.each(AVAILABLE_BOSS_IDS)("moves %s back toward its center slot while idle", (bossId) => {
|
||||||
const motion = createBossMotionState(bossId);
|
const motion = createBossMotionState(bossId);
|
||||||
motion.position = [7, 5];
|
motion.position = [7, 5];
|
||||||
motion.nextChargeAt = Number.POSITIVE_INFINITY;
|
|
||||||
motion.nextMechanicAt = Number.POSITIVE_INFINITY;
|
motion.nextMechanicAt = Number.POSITIVE_INFINITY;
|
||||||
motion.nextPoolMechanicAt = Number.POSITIVE_INFINITY;
|
|
||||||
const before = Math.hypot(
|
const before = Math.hypot(
|
||||||
motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX),
|
motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX),
|
||||||
motion.position[1] - ARENA_CENTER[1],
|
motion.position[1] - ARENA_CENTER[1],
|
||||||
|
|||||||
+29
-423
@@ -1,447 +1,53 @@
|
|||||||
import { BOSS_ARCHETYPE_BY_ID, BOSS_DEFINITIONS, type BossArchetype } from "./bossCatalog";
|
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||||
import { clampToArena } from "./arena";
|
import {
|
||||||
import { advanceSkySweeperMechanics, createSkySweeperMotion, createSkySweeperState, upcomingSkySweeperMechanic } from "./bosses/skySweeper";
|
advanceMechanicLoadout,
|
||||||
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/ricochet";
|
BOSS_MECHANIC_REGISTRY,
|
||||||
import { advanceCragclawMechanics, createCragclawMotion, createCragclawState, upcomingCragclawMechanic } from "./bosses/cragclawCrab";
|
BULL_CHARGE,
|
||||||
import { advanceCrownshardMechanics, createCrownshardMotion, createCrownshardState, upcomingCrownshardMechanic } from "./bosses/crownshardGolem";
|
BULL_POUNCE,
|
||||||
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
|
handleMechanicDispel,
|
||||||
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, upcomingMournveilMechanic } from "./bosses/mournveilGhost";
|
upcomingLoadoutMechanic,
|
||||||
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, upcomingObsidianRamMechanic } from "./bosses/obsidianRamGolem";
|
} from "./bosses/mechanicPool";
|
||||||
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, upcomingSandglassMechanic } from "./bosses/sandglassScorpion";
|
import { createBaseMotion } from "./bosses/shared";
|
||||||
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared";
|
import type { BossMechanicContext, BossMechanicResult } from "./bosses/types";
|
||||||
import { advancePooledBossMechanics, upcomingPooledMechanic } from "./bosses/mechanicPool";
|
import type { BossId, BossMotionState, BossState, MemberId, WorldPosition } from "./types";
|
||||||
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
|
|
||||||
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
|
|
||||||
import { distance, moveToward, pointToSegmentDistance } from "./geometry";
|
|
||||||
import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "./types";
|
|
||||||
|
|
||||||
export const BULL_CHARGE = {
|
export { BOSS_MECHANIC_REGISTRY, BULL_CHARGE, BULL_POUNCE };
|
||||||
firstAt: 7,
|
|
||||||
repeatDelay: 4,
|
|
||||||
telegraphDuration: 1.8,
|
|
||||||
distance: 13.5,
|
|
||||||
speed: 10.5,
|
|
||||||
hitRadius: 1.35,
|
|
||||||
damage: 18,
|
|
||||||
knockdownDuration: 0.75,
|
|
||||||
aiClearance: 1.9,
|
|
||||||
aiEvadeSpeed: 3.4,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const BULL_POUNCE = {
|
|
||||||
afterCharges: 3,
|
|
||||||
stackDuration: 5,
|
|
||||||
stackRadius: 2.2,
|
|
||||||
sharedDamage: 200,
|
|
||||||
leapDuration: 0.55,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const BOSS_PERIODIC_MECHANICS = {
|
|
||||||
melee: { firstAt: 2, interval: 2.5, damage: 15 },
|
|
||||||
nova: { firstAt: 9, interval: 12, damage: 13 },
|
|
||||||
brand: { firstAt: 5, interval: 9, duration: 7, tickDamage: 6 },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const CHARGE_TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
|
|
||||||
const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
|
|
||||||
|
|
||||||
export function createBossState(bossId: BossId = "bulldrome"): BossState {
|
export function createBossState(bossId: BossId = "bulldrome"): BossState {
|
||||||
const definition = BOSS_DEFINITIONS[bossId];
|
const definition = BOSS_DEFINITIONS[bossId];
|
||||||
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
|
return {
|
||||||
let state: BossState;
|
id: bossId,
|
||||||
if (archetype === "web-caster") state = createVexaState(bossId);
|
name: definition.name,
|
||||||
else if (archetype === "sky-sweeper") state = createSkySweeperState(bossId);
|
maxHp: definition.maxHp,
|
||||||
else if (archetype === "duelist") state = createEmberMantisState(bossId);
|
hp: definition.maxHp,
|
||||||
else if (archetype === "ram") state = createObsidianRamState(bossId);
|
nextMeleeAt: 2,
|
||||||
else if (archetype === "ricochet") state = createCinderbackState(bossId);
|
};
|
||||||
else if (archetype === "burrower") state = createSandglassState();
|
|
||||||
else if (archetype === "crab") state = createCragclawState();
|
|
||||||
else if (archetype === "ghost") state = createMournveilState();
|
|
||||||
else if (archetype === "golem") state = createCrownshardState();
|
|
||||||
else {
|
|
||||||
state = {
|
|
||||||
id: bossId,
|
|
||||||
name: definition.name,
|
|
||||||
maxHp: definition.maxHp,
|
|
||||||
hp: definition.maxHp,
|
|
||||||
nextMeleeAt: BOSS_PERIODIC_MECHANICS.melee.firstAt,
|
|
||||||
nextNovaAt: BOSS_PERIODIC_MECHANICS.nova.firstAt,
|
|
||||||
nextBrandAt: BOSS_PERIODIC_MECHANICS.brand.firstAt,
|
|
||||||
brandCount: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ...state, id: bossId, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
|
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
|
||||||
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
|
return {
|
||||||
let motion: BossMotionState;
|
...createBaseMotion(bossId),
|
||||||
if (archetype === "web-caster") motion = createVexaMotion(bossId);
|
position: [0, -6.8],
|
||||||
else if (archetype === "sky-sweeper") motion = createSkySweeperMotion(bossId);
|
chargeStart: [0, -6.8],
|
||||||
else if (archetype === "duelist") motion = createEmberMantisMotion(bossId);
|
nextMechanicAt: 5,
|
||||||
else if (archetype === "ram") motion = createObsidianRamMotion(bossId);
|
|
||||||
else if (archetype === "ricochet") motion = createCinderbackMotion(bossId);
|
|
||||||
else if (archetype === "burrower") motion = createSandglassMotion();
|
|
||||||
else if (archetype === "crab") motion = createCragclawMotion();
|
|
||||||
else if (archetype === "ghost") motion = createMournveilMotion();
|
|
||||||
else if (archetype === "golem") motion = createCrownshardMotion();
|
|
||||||
else motion = {
|
|
||||||
...createBaseMotion("bulldrome"),
|
|
||||||
mode: "holding",
|
|
||||||
position: [0, -8.2],
|
|
||||||
chargeStart: [0, -8.2],
|
|
||||||
chargeEnd: [0, 5.5],
|
|
||||||
chargeTargetId: "nia",
|
|
||||||
chargeHitIds: [],
|
|
||||||
phaseEndsAt: 0,
|
|
||||||
nextChargeAt: BULL_CHARGE.firstAt,
|
|
||||||
chargeCount: 0,
|
|
||||||
chargesSincePounce: 0,
|
|
||||||
pounceTargetId: "aelia",
|
|
||||||
pounceCenter: [0, 4.5],
|
|
||||||
pounceCount: 0,
|
|
||||||
};
|
};
|
||||||
return { ...motion, bossId };
|
|
||||||
}
|
|
||||||
|
|
||||||
function mechanicArchetype(bossId: BossId): BossArchetype {
|
|
||||||
return BOSS_ARCHETYPE_BY_ID[bossId];
|
|
||||||
}
|
|
||||||
|
|
||||||
function chargeEndpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
|
|
||||||
const dx = target[0] - start[0];
|
|
||||||
const dz = target[1] - start[1];
|
|
||||||
const length = Math.max(0.001, Math.hypot(dx, dz));
|
|
||||||
return clampToArena([
|
|
||||||
start[0] + (dx / length) * BULL_CHARGE.distance,
|
|
||||||
start[1] + (dz / length) * BULL_CHARGE.distance,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function livingMember(party: PartyMember[], memberId: MemberId) {
|
|
||||||
return party.find((member) => member.id === memberId && member.hp > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseTarget(party: PartyMember[], order: readonly MemberId[], startIndex: number): MemberId {
|
|
||||||
for (let offset = 0; offset < order.length; offset += 1) {
|
|
||||||
const candidate = order[(startIndex + offset) % order.length];
|
|
||||||
if (livingMember(party, candidate)) return candidate;
|
|
||||||
}
|
|
||||||
return order[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function advanceMotionMechanics(
|
|
||||||
source: BossMotionState,
|
|
||||||
party: PartyMember[],
|
|
||||||
partyPositions: Record<MemberId, WorldPosition>,
|
|
||||||
time: number,
|
|
||||||
delta: number,
|
|
||||||
damageMember: BossMechanicContext["damageMember"],
|
|
||||||
events: BossMechanicEvent[],
|
|
||||||
) {
|
|
||||||
let motion: BossMotionState = {
|
|
||||||
...source,
|
|
||||||
position: [source.position[0], source.position[1]],
|
|
||||||
chargeStart: [source.chargeStart[0], source.chargeStart[1]],
|
|
||||||
chargeEnd: [source.chargeEnd[0], source.chargeEnd[1]],
|
|
||||||
chargeHitIds: [...source.chargeHitIds],
|
|
||||||
pounceCenter: [source.pounceCenter[0], source.pounceCenter[1]],
|
|
||||||
};
|
|
||||||
let updatedParty = party;
|
|
||||||
|
|
||||||
if (motion.mode === "holding") {
|
|
||||||
returnBossToArenaCenter(motion, delta, 1.8);
|
|
||||||
if (time >= motion.nextChargeAt) {
|
|
||||||
const targetId = chooseTarget(updatedParty, CHARGE_TARGET_ORDER, motion.chargeCount);
|
|
||||||
const target = partyPositions[targetId];
|
|
||||||
const targetName = updatedParty.find((member) => member.id === targetId)?.name ?? targetId;
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "telegraph",
|
|
||||||
chargeStart: [motion.position[0], motion.position[1]],
|
|
||||||
chargeEnd: chargeEndpoint(motion.position, target),
|
|
||||||
chargeTargetId: targetId,
|
|
||||||
chargeHitIds: [],
|
|
||||||
phaseEndsAt: time + BULL_CHARGE.telegraphDuration,
|
|
||||||
nextChargeAt: Number.POSITIVE_INFINITY,
|
|
||||||
chargeCount: motion.chargeCount + 1,
|
|
||||||
chargesSincePounce: motion.chargesSincePounce + 1,
|
|
||||||
};
|
|
||||||
events.push({
|
|
||||||
at: time,
|
|
||||||
message: `Bulldrome lines up a charge on ${targetName}.`,
|
|
||||||
tone: "danger",
|
|
||||||
pulseKind: "charge",
|
|
||||||
targetId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "telegraph" && time >= motion.phaseEndsAt) {
|
|
||||||
const chargeDuration = distance(motion.chargeStart, motion.chargeEnd) / BULL_CHARGE.speed;
|
|
||||||
motion = { ...motion, mode: "charging", phaseEndsAt: time + chargeDuration };
|
|
||||||
events.push({ at: time, message: "Bulldrome charges! Clear the marked lane.", tone: "danger" });
|
|
||||||
} else if (motion.mode === "charging") {
|
|
||||||
const previousPosition: WorldPosition = [motion.position[0], motion.position[1]];
|
|
||||||
motion.position = moveToward(motion.position, motion.chargeEnd, BULL_CHARGE.speed * delta);
|
|
||||||
updatedParty = updatedParty.map((member) => {
|
|
||||||
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
|
|
||||||
if (pointToSegmentDistance(partyPositions[member.id], previousPosition, motion.position) > BULL_CHARGE.hitRadius) {
|
|
||||||
return member;
|
|
||||||
}
|
|
||||||
motion.chargeHitIds = [...motion.chargeHitIds, member.id];
|
|
||||||
events.push({
|
|
||||||
at: time,
|
|
||||||
message: `${member.name} is knocked down by the charge.`,
|
|
||||||
tone: "danger",
|
|
||||||
pulseKind: "charge",
|
|
||||||
targetId: member.id,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...damageMember(member, BULL_CHARGE.damage, partyPositions[member.id], time),
|
|
||||||
knockedUntil: time + BULL_CHARGE.knockdownDuration,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (distance(motion.position, motion.chargeEnd) < 0.05 || time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, position: [motion.chargeEnd[0], motion.chargeEnd[1]], mode: "returning", phaseEndsAt: 0 };
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "returning") {
|
|
||||||
if (returnBossToArenaCenter(motion, delta, 4.4)) {
|
|
||||||
if (motion.chargesSincePounce >= BULL_POUNCE.afterCharges) {
|
|
||||||
const targetId = chooseTarget(updatedParty, POUNCE_TARGET_ORDER, motion.pounceCount);
|
|
||||||
const targetName = updatedParty.find((member) => member.id === targetId)?.name ?? targetId;
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "stacking",
|
|
||||||
phaseEndsAt: time + BULL_POUNCE.stackDuration,
|
|
||||||
nextChargeAt: Number.POSITIVE_INFINITY,
|
|
||||||
chargesSincePounce: 0,
|
|
||||||
pounceTargetId: targetId,
|
|
||||||
pounceCenter: [partyPositions[targetId][0], partyPositions[targetId][1]],
|
|
||||||
pounceCount: motion.pounceCount + 1,
|
|
||||||
};
|
|
||||||
events.push({
|
|
||||||
at: time,
|
|
||||||
message: `Bulldrome marks ${targetName}. Stack inside the circle!`,
|
|
||||||
tone: "danger",
|
|
||||||
pulseKind: "pounce",
|
|
||||||
targetId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "holding",
|
|
||||||
nextChargeAt: time + BULL_CHARGE.repeatDelay,
|
|
||||||
};
|
|
||||||
events.push({ at: time, message: "Bulldrome returns to Brann and paws at the stone." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "stacking") {
|
|
||||||
motion.pounceCenter = [partyPositions[motion.pounceTargetId][0], partyPositions[motion.pounceTargetId][1]];
|
|
||||||
if (time >= motion.phaseEndsAt) {
|
|
||||||
const targetName = updatedParty.find((member) => member.id === motion.pounceTargetId)?.name ?? motion.pounceTargetId;
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "pouncing",
|
|
||||||
chargeStart: [motion.position[0], motion.position[1]],
|
|
||||||
chargeEnd: [motion.pounceCenter[0], motion.pounceCenter[1]],
|
|
||||||
phaseEndsAt: time + BULL_POUNCE.leapDuration,
|
|
||||||
};
|
|
||||||
events.push({ at: time, message: `Bulldrome leaps at ${targetName}!`, tone: "danger" });
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "pouncing") {
|
|
||||||
const leapDistance = distance(motion.chargeStart, motion.chargeEnd);
|
|
||||||
const leapSpeed = Math.max(12, leapDistance / BULL_POUNCE.leapDuration);
|
|
||||||
motion.position = moveToward(motion.position, motion.chargeEnd, leapSpeed * delta);
|
|
||||||
if (distance(motion.position, motion.chargeEnd) < 0.05 || time >= motion.phaseEndsAt) {
|
|
||||||
const stackedIds = updatedParty
|
|
||||||
.filter((member) => member.hp > 0 && distance(partyPositions[member.id], motion.pounceCenter) <= BULL_POUNCE.stackRadius)
|
|
||||||
.map((member) => member.id);
|
|
||||||
const sharedDamage = BULL_POUNCE.sharedDamage / Math.max(1, stackedIds.length);
|
|
||||||
updatedParty = updatedParty.map((member) => stackedIds.includes(member.id)
|
|
||||||
? damageMember(member, sharedDamage, partyPositions[member.id], time)
|
|
||||||
: member);
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
position: [motion.chargeEnd[0], motion.chargeEnd[1]],
|
|
||||||
mode: "returning",
|
|
||||||
phaseEndsAt: 0,
|
|
||||||
};
|
|
||||||
events.push({
|
|
||||||
at: time,
|
|
||||||
message: `Bulldrome pounces for ${Math.round(sharedDamage)} damage across ${stackedIds.length} stacked allies.`,
|
|
||||||
tone: "danger",
|
|
||||||
pulseKind: "pounce",
|
|
||||||
targetId: motion.pounceTargetId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { motion, party: updatedParty };
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePeriodicMechanics(
|
|
||||||
boss: BossState,
|
|
||||||
motion: BossMotionState,
|
|
||||||
party: PartyMember[],
|
|
||||||
partyPositions: Record<MemberId, WorldPosition>,
|
|
||||||
time: number,
|
|
||||||
damageMember: BossMechanicContext["damageMember"],
|
|
||||||
events: BossMechanicEvent[],
|
|
||||||
) {
|
|
||||||
let updatedParty = party;
|
|
||||||
|
|
||||||
while (boss.nextMeleeAt <= time) {
|
|
||||||
if (motion.mode === "holding") {
|
|
||||||
const tankIndex = updatedParty.findIndex((member) => member.id === "brann");
|
|
||||||
updatedParty[tankIndex] = damageMember(
|
|
||||||
updatedParty[tankIndex],
|
|
||||||
BOSS_PERIODIC_MECHANICS.melee.damage,
|
|
||||||
partyPositions.brann,
|
|
||||||
boss.nextMeleeAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
boss.nextMeleeAt += BOSS_PERIODIC_MECHANICS.melee.interval;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (boss.nextNovaAt <= time) {
|
|
||||||
updatedParty = updatedParty.map((member) => damageMember(
|
|
||||||
member,
|
|
||||||
BOSS_PERIODIC_MECHANICS.nova.damage,
|
|
||||||
partyPositions[member.id],
|
|
||||||
boss.nextNovaAt,
|
|
||||||
));
|
|
||||||
events.push({
|
|
||||||
at: boss.nextNovaAt,
|
|
||||||
message: "Cinder Nova strikes the party.",
|
|
||||||
tone: "danger",
|
|
||||||
pulseKind: "boss",
|
|
||||||
});
|
|
||||||
boss.nextNovaAt += BOSS_PERIODIC_MECHANICS.nova.interval;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (boss.nextBrandAt <= time) {
|
|
||||||
const targetId = CHARGE_TARGET_ORDER[boss.brandCount % CHARGE_TARGET_ORDER.length];
|
|
||||||
const targetIndex = updatedParty.findIndex((member) => member.id === targetId);
|
|
||||||
if (updatedParty[targetIndex].hp > 0) {
|
|
||||||
const appliedAt = boss.nextBrandAt;
|
|
||||||
updatedParty[targetIndex] = {
|
|
||||||
...updatedParty[targetIndex],
|
|
||||||
debuffs: [
|
|
||||||
...updatedParty[targetIndex].debuffs,
|
|
||||||
{
|
|
||||||
id: `brand-${boss.brandCount}`,
|
|
||||||
name: "Ember Brand",
|
|
||||||
expiresAt: appliedAt + BOSS_PERIODIC_MECHANICS.brand.duration,
|
|
||||||
nextTickAt: appliedAt + 1,
|
|
||||||
tickDamage: BOSS_PERIODIC_MECHANICS.brand.tickDamage,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
events.push({
|
|
||||||
at: appliedAt,
|
|
||||||
message: `Ember Brand afflicts ${updatedParty[targetIndex].name}.`,
|
|
||||||
tone: "danger",
|
|
||||||
pulseKind: "debuff",
|
|
||||||
targetId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
boss.brandCount += 1;
|
|
||||||
boss.nextBrandAt += BOSS_PERIODIC_MECHANICS.brand.interval;
|
|
||||||
}
|
|
||||||
return updatedParty;
|
|
||||||
}
|
|
||||||
|
|
||||||
function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicResult {
|
|
||||||
const boss = { ...context.boss };
|
|
||||||
const events: BossMechanicEvent[] = [];
|
|
||||||
const motionResult = advanceMotionMechanics(
|
|
||||||
context.motion,
|
|
||||||
context.party,
|
|
||||||
context.partyPositions,
|
|
||||||
context.time,
|
|
||||||
context.delta,
|
|
||||||
context.damageMember,
|
|
||||||
events,
|
|
||||||
);
|
|
||||||
const party = resolvePeriodicMechanics(
|
|
||||||
boss,
|
|
||||||
motionResult.motion,
|
|
||||||
motionResult.party,
|
|
||||||
context.partyPositions,
|
|
||||||
context.time,
|
|
||||||
context.damageMember,
|
|
||||||
events,
|
|
||||||
);
|
|
||||||
return { boss, motion: motionResult.motion, party, events };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
|
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||||
const archetype = mechanicArchetype(context.boss.id);
|
return advanceMechanicLoadout(context, BOSS_DEFINITIONS[context.boss.id].mechanicIds);
|
||||||
const result = archetype === "web-caster" ? advanceVexaMechanics(context)
|
|
||||||
: archetype === "sky-sweeper" ? advanceSkySweeperMechanics(context)
|
|
||||||
: archetype === "duelist" ? advanceEmberMantisMechanics(context)
|
|
||||||
: archetype === "ram" ? advanceObsidianRamMechanics(context)
|
|
||||||
: archetype === "ricochet" ? advanceCinderbackMechanics(context)
|
|
||||||
: archetype === "burrower" ? advanceSandglassMechanics(context)
|
|
||||||
: archetype === "crab" ? advanceCragclawMechanics(context)
|
|
||||||
: archetype === "ghost" ? advanceMournveilMechanics(context)
|
|
||||||
: archetype === "golem" ? advanceCrownshardMechanics(context)
|
|
||||||
: advanceBulldromeMechanics(context);
|
|
||||||
return advancePooledBossMechanics(context, result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleBossDispel(
|
export function handleBossDispel(
|
||||||
bossId: BossId,
|
_bossId: BossId,
|
||||||
motion: BossMotionState,
|
motion: BossMotionState,
|
||||||
memberId: MemberId,
|
memberId: MemberId,
|
||||||
position: WorldPosition,
|
position: WorldPosition,
|
||||||
time: number,
|
time: number,
|
||||||
debuffNames: readonly string[],
|
debuffNames: readonly string[],
|
||||||
) {
|
) {
|
||||||
if (mechanicArchetype(bossId) === "web-caster" && debuffNames.includes("Widow Venom")) {
|
return handleMechanicDispel(motion, memberId, position, time, debuffNames);
|
||||||
return {
|
|
||||||
motion: dropVexaVenomPool(motion, memberId, [position[0], position[1]], time),
|
|
||||||
message: "Widow Venom purged. A venom pool forms where the target stood.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { motion, message: "Harmful magic removed." };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
|
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
|
||||||
const pooled = upcomingPooledMechanic(motion, time);
|
return upcomingLoadoutMechanic(BOSS_DEFINITIONS[boss.id].mechanicIds, motion, time);
|
||||||
if (pooled) return pooled;
|
|
||||||
const archetype = mechanicArchetype(boss.id);
|
|
||||||
if (archetype === "web-caster") return upcomingVexaMechanic(boss, motion, time);
|
|
||||||
if (archetype === "sky-sweeper") return upcomingSkySweeperMechanic(boss, motion, time);
|
|
||||||
if (archetype === "duelist") return upcomingEmberMantisMechanic(boss, motion, time);
|
|
||||||
if (archetype === "ram") return upcomingObsidianRamMechanic(boss, motion, time);
|
|
||||||
if (archetype === "ricochet") return upcomingCinderbackMechanic(boss, motion, time);
|
|
||||||
if (archetype === "burrower") return upcomingSandglassMechanic(boss, motion, time);
|
|
||||||
if (archetype === "crab") return upcomingCragclawMechanic(boss, motion, time);
|
|
||||||
if (archetype === "ghost") return upcomingMournveilMechanic(boss, motion, time);
|
|
||||||
if (archetype === "golem") return upcomingCrownshardMechanic(boss, motion, time);
|
|
||||||
if (motion.mode === "telegraph") {
|
|
||||||
return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true };
|
|
||||||
}
|
|
||||||
if (motion.mode === "charging") {
|
|
||||||
return { name: "Charge active", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
|
|
||||||
}
|
|
||||||
if (motion.mode === "stacking") {
|
|
||||||
const names: Record<MemberId, string> = { aelia: "Aelia", brann: "Brann", nia: "Nia", orin: "Orin", vale: "Vale" };
|
|
||||||
return { name: `Stack on ${names[motion.pounceTargetId]}`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_POUNCE.stackDuration, urgent: true };
|
|
||||||
}
|
|
||||||
if (motion.mode === "pouncing") {
|
|
||||||
return { name: "Bulldrome Pounce", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 0.75, urgent: true };
|
|
||||||
}
|
|
||||||
const candidates: Array<{ name: string; remaining: number; cycle: number }> = [
|
|
||||||
{ name: "Cinder Nova", remaining: Math.max(0, boss.nextNovaAt - time), cycle: BOSS_PERIODIC_MECHANICS.nova.interval },
|
|
||||||
{ name: "Ember Brand", remaining: Math.max(0, boss.nextBrandAt - time), cycle: BOSS_PERIODIC_MECHANICS.brand.interval },
|
|
||||||
];
|
|
||||||
if (motion.mode === "holding" && Number.isFinite(motion.nextChargeAt)) {
|
|
||||||
candidates.push({ name: "Bull Charge", remaining: Math.max(0, motion.nextChargeAt - time), cycle: BULL_CHARGE.firstAt + 1 });
|
|
||||||
}
|
|
||||||
let next = candidates[0];
|
|
||||||
for (let index = 1; index < candidates.length; index += 1) {
|
|
||||||
if (candidates[index].remaining < next.remaining) next = candidates[index];
|
|
||||||
}
|
|
||||||
return { ...next, urgent: next.remaining < 2.5 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { freshParty } from "../data";
|
|
||||||
import type { BossMotionState, BossState, WorldPosition } from "../types";
|
|
||||||
import { advanceCragclawMechanics, CRAGCLAW, createCragclawMotion, createCragclawState } from "./cragclawCrab";
|
|
||||||
import { advanceCrownshardMechanics, CROWNSHARD, createCrownshardMotion, createCrownshardState } from "./crownshardGolem";
|
|
||||||
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, MOURNVEIL } from "./mournveilGhost";
|
|
||||||
import type { BossMechanicContext } from "./types";
|
|
||||||
|
|
||||||
const POSITIONS: BossMechanicContext["partyPositions"] = {
|
|
||||||
aelia: [0, 4.5],
|
|
||||||
brann: [0, 0],
|
|
||||||
nia: [-3, 2],
|
|
||||||
orin: [3, 2],
|
|
||||||
vale: [0, -2],
|
|
||||||
};
|
|
||||||
|
|
||||||
function context(
|
|
||||||
boss: BossState,
|
|
||||||
motion: BossMotionState,
|
|
||||||
time: number,
|
|
||||||
delta = 0.1,
|
|
||||||
positions = POSITIONS,
|
|
||||||
party = freshParty(),
|
|
||||||
): BossMechanicContext {
|
|
||||||
return {
|
|
||||||
boss,
|
|
||||||
motion,
|
|
||||||
party,
|
|
||||||
partyPositions: structuredClone(positions),
|
|
||||||
time,
|
|
||||||
delta,
|
|
||||||
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ClaudeCraft boss trio mechanics", () => {
|
|
||||||
it("telegraphs and resolves Cragclaw Sidewinder Rush", () => {
|
|
||||||
const start = advanceCragclawMechanics(context(createCragclawState(), createCragclawMotion(), CRAGCLAW.firstAt));
|
|
||||||
expect(start.motion.mode).toBe("crab_scuttle_telegraph");
|
|
||||||
expect(start.motion.slashLanes).toHaveLength(1);
|
|
||||||
|
|
||||||
const active = advanceCragclawMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
|
|
||||||
expect(active.motion.mode).toBe("crab_scuttling");
|
|
||||||
const niaBefore = active.party.find((member) => member.id === "nia")!.hp;
|
|
||||||
const impact = advanceCragclawMechanics(context(active.boss, active.motion, active.motion.phaseEndsAt, 2, POSITIONS, active.party));
|
|
||||||
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - CRAGCLAW.scuttleDamage);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("places three Crushing Tide warnings on party positions", () => {
|
|
||||||
const motion = { ...createCragclawMotion(), mechanicCount: 1, nextMechanicAt: 0 };
|
|
||||||
const start = advanceCragclawMechanics(context(createCragclawState(), motion, 0));
|
|
||||||
expect(start.motion.mode).toBe("crab_tidal_burst");
|
|
||||||
expect(start.motion.hazards.filter((hazard) => hazard.kind === "tidal_burst")).toHaveLength(3);
|
|
||||||
|
|
||||||
const positions = structuredClone(POSITIONS);
|
|
||||||
positions.aelia = [...start.motion.hazards[0].center] as WorldPosition;
|
|
||||||
const hpBefore = start.party.find((member) => member.id === "aelia")!.hp;
|
|
||||||
const impact = advanceCragclawMechanics(context(start.boss, start.motion, CRAGCLAW.tidalWarning + 0.05, 0.1, positions, start.party));
|
|
||||||
expect(impact.party.find((member) => member.id === "aelia")!.hp).toBe(hpBefore - CRAGCLAW.tidalDamage);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rotates Mournveil Soul Scissors for a second crossing pattern", () => {
|
|
||||||
const start = advanceMournveilMechanics(context(createMournveilState(), createMournveilMotion(), MOURNVEIL.firstAt));
|
|
||||||
const firstLaneIds = start.motion.slashLanes.map((lane) => lane.id);
|
|
||||||
expect(start.motion.mode).toBe("ghost_soul_cross");
|
|
||||||
expect(start.motion.slashLanes).toHaveLength(2);
|
|
||||||
|
|
||||||
const followup = advanceMournveilMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
|
|
||||||
expect(followup.motion.mode).toBe("ghost_soul_cross_followup");
|
|
||||||
expect(followup.motion.slashLanes).toHaveLength(2);
|
|
||||||
expect(followup.motion.slashLanes.map((lane) => lane.id)).not.toEqual(firstLaneIds);
|
|
||||||
|
|
||||||
const resolved = advanceMournveilMechanics(context(followup.boss, followup.motion, followup.motion.phaseEndsAt, 0.1, POSITIONS, followup.party));
|
|
||||||
expect(resolved.motion.mode).toBe("ghost_recover");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens two persistent Haunting Rifts", () => {
|
|
||||||
const motion = { ...createMournveilMotion(), mechanicCount: 1, nextMechanicAt: 0 };
|
|
||||||
const start = advanceMournveilMechanics(context(createMournveilState(), motion, 0));
|
|
||||||
const rifts = start.motion.hazards.filter((hazard) => hazard.kind === "soul_rift");
|
|
||||||
expect(start.motion.mode).toBe("ghost_haunting");
|
|
||||||
expect(rifts).toHaveLength(2);
|
|
||||||
expect(rifts[0].expiresAt - rifts[0].activatesAt).toBe(MOURNVEIL.riftDuration);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds three non-overlapping Crownshard shockwave bands", () => {
|
|
||||||
const start = advanceCrownshardMechanics(context(createCrownshardState(), createCrownshardMotion(), CROWNSHARD.firstAt));
|
|
||||||
const rings = start.motion.hazards.filter((hazard) => hazard.kind === "royal_shockwave");
|
|
||||||
expect(start.motion.mode).toBe("golem_shockwave");
|
|
||||||
expect(rings).toHaveLength(3);
|
|
||||||
expect(rings.map((ring) => [ring.innerRadius ?? 0, ring.radius])).toEqual([[0, 2.35], [2.35, 4.7], [4.7, 7.05]]);
|
|
||||||
|
|
||||||
const positions = structuredClone(POSITIONS);
|
|
||||||
positions.aelia = [...rings[0].center];
|
|
||||||
positions.nia = [rings[0].center[0] + 3, rings[0].center[1]];
|
|
||||||
const first = advanceCrownshardMechanics(context(start.boss, start.motion, rings[0].activatesAt + 0.05, 0.1, positions, start.party));
|
|
||||||
const aeliaAfterFirst = first.party.find((member) => member.id === "aelia")!.hp;
|
|
||||||
const niaAfterFirst = first.party.find((member) => member.id === "nia")!.hp;
|
|
||||||
expect(aeliaAfterFirst).toBe(start.party.find((member) => member.id === "aelia")!.hp - CROWNSHARD.shockwaveDamage);
|
|
||||||
expect(niaAfterFirst).toBe(start.party.find((member) => member.id === "nia")!.hp);
|
|
||||||
|
|
||||||
const second = advanceCrownshardMechanics(context(first.boss, first.motion, rings[1].activatesAt + 0.05, 0.1, positions, first.party));
|
|
||||||
expect(second.party.find((member) => member.id === "aelia")!.hp).toBe(aeliaAfterFirst);
|
|
||||||
expect(second.party.find((member) => member.id === "nia")!.hp).toBe(niaAfterFirst - CROWNSHARD.shockwaveDamage);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks three allies with Crownfall", () => {
|
|
||||||
const motion = { ...createCrownshardMotion(), mechanicCount: 1, nextMechanicAt: 0 };
|
|
||||||
const result = advanceCrownshardMechanics(context(createCrownshardState(), motion, 0));
|
|
||||||
expect(result.motion.mode).toBe("golem_crownfall");
|
|
||||||
expect(result.motion.hazards.filter((hazard) => hazard.kind === "crownfall")).toHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { clampToArena } from "../arena";
|
|
||||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
|
||||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
|
||||||
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
|
||||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
|
||||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
|
||||||
|
|
||||||
export const CRAGCLAW = {
|
|
||||||
firstAt: 5.1,
|
|
||||||
repeatDelay: 3.7,
|
|
||||||
scuttleWarning: 1.35,
|
|
||||||
scuttleSpeed: 11.8,
|
|
||||||
scuttleDistance: 13,
|
|
||||||
scuttleWidth: 2.35,
|
|
||||||
scuttleDamage: 24,
|
|
||||||
tidalWarning: 1.45,
|
|
||||||
tidalRadius: 1.7,
|
|
||||||
tidalDamage: 26,
|
|
||||||
recoverDuration: 0.72,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const SCUTTLE_TARGETS: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
|
|
||||||
const TIDAL_TARGETS: readonly (readonly MemberId[])[] = [
|
|
||||||
["aelia", "nia", "orin"],
|
|
||||||
["brann", "vale", "aelia"],
|
|
||||||
["nia", "orin", "vale"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createCragclawState(): BossState {
|
|
||||||
const definition = BOSS_DEFINITIONS["cragclaw-crab"];
|
|
||||||
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCragclawMotion(): BossMotionState {
|
|
||||||
return { ...createBaseMotion("cragclaw-crab"), position: [0, -6.4], nextMechanicAt: CRAGCLAW.firstAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scuttleEnd(start: WorldPosition, target: WorldPosition): WorldPosition {
|
|
||||||
const angle = angleTo(start, target);
|
|
||||||
return clampToArena([
|
|
||||||
start[0] + Math.sin(angle) * CRAGCLAW.scuttleDistance,
|
|
||||||
start[1] + Math.cos(angle) * CRAGCLAW.scuttleDistance,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
|
|
||||||
const mechanicCount = motion.mechanicCount + 1;
|
|
||||||
if (motion.mechanicCount % 2 === 0) {
|
|
||||||
const targetId = chooseLivingTarget(context.party, SCUTTLE_TARGETS, motion.mechanicCount);
|
|
||||||
const end = scuttleEnd(motion.position, context.partyPositions[targetId]);
|
|
||||||
const lane: SlashLane = {
|
|
||||||
id: `cragclaw-scuttle-${mechanicCount}`,
|
|
||||||
start: [...motion.position],
|
|
||||||
end,
|
|
||||||
width: CRAGCLAW.scuttleWidth,
|
|
||||||
damage: CRAGCLAW.scuttleDamage,
|
|
||||||
};
|
|
||||||
events.push({ at: context.time, message: `Cragclaw lines up Sidewinder Rush on ${memberName(context.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
|
|
||||||
return {
|
|
||||||
...motion,
|
|
||||||
mode: "crab_scuttle_telegraph" as const,
|
|
||||||
chargeTargetId: targetId,
|
|
||||||
chargeStart: [...motion.position] as WorldPosition,
|
|
||||||
chargeEnd: end,
|
|
||||||
chargeHitIds: [],
|
|
||||||
slashLanes: [lane],
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: context.time + CRAGCLAW.scuttleWarning,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
mechanicCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const activatesAt = context.time + CRAGCLAW.tidalWarning;
|
|
||||||
const targetSet = TIDAL_TARGETS[Math.floor(motion.mechanicCount / 2) % TIDAL_TARGETS.length];
|
|
||||||
events.push({ at: context.time, message: "Crushing Tide marks three allies. Spread before the claws close.", tone: "danger", pulseKind: "skyfall" });
|
|
||||||
return {
|
|
||||||
...motion,
|
|
||||||
mode: "crab_tidal_burst" as const,
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: activatesAt + 0.3,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
mechanicCount,
|
|
||||||
hazards: [
|
|
||||||
...motion.hazards,
|
|
||||||
...targetSet.map((targetId, index) => createCircleHazard({
|
|
||||||
id: `cragclaw-tide-${mechanicCount}-${index}`,
|
|
||||||
kind: "tidal_burst",
|
|
||||||
center: context.partyPositions[targetId],
|
|
||||||
radius: CRAGCLAW.tidalRadius,
|
|
||||||
activatesAt,
|
|
||||||
duration: 0.3,
|
|
||||||
damage: CRAGCLAW.tidalDamage,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advanceCragclawMechanics(context: BossMechanicContext): BossMechanicResult {
|
|
||||||
const boss = { ...context.boss };
|
|
||||||
let motion = cloneMotion(context.motion);
|
|
||||||
const events: BossMechanicResult["events"] = [];
|
|
||||||
let party = context.party;
|
|
||||||
|
|
||||||
if (motion.mode === "holding") {
|
|
||||||
returnBossToArenaCenter(motion, context.delta, 2.05);
|
|
||||||
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
|
|
||||||
} else if (motion.mode === "crab_scuttle_telegraph" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "crab_scuttling",
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / CRAGCLAW.scuttleSpeed,
|
|
||||||
};
|
|
||||||
events.push({ at: context.time, message: "Sidewinder Rush! Clear the surf lane.", tone: "danger", pulseKind: "charge" });
|
|
||||||
} else if (motion.mode === "crab_scuttling") {
|
|
||||||
const previous = [...motion.position] as WorldPosition;
|
|
||||||
motion.position = moveToward(motion.position, motion.chargeEnd, CRAGCLAW.scuttleSpeed * context.delta);
|
|
||||||
party = party.map((member) => {
|
|
||||||
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
|
|
||||||
if (pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > CRAGCLAW.scuttleWidth * 0.5) return member;
|
|
||||||
motion.chargeHitIds.push(member.id);
|
|
||||||
events.push({ at: context.time, message: `${member.name} is crushed by Sidewinder Rush.`, tone: "danger", pulseKind: "charge", targetId: member.id });
|
|
||||||
return { ...context.damageMember(member, CRAGCLAW.scuttleDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.42 };
|
|
||||||
});
|
|
||||||
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, position: [...motion.chargeEnd], mode: "crab_recover", phaseEndsAt: context.time + CRAGCLAW.recoverDuration };
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "crab_tidal_burst" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "crab_recover", phaseEndsAt: context.time + CRAGCLAW.recoverDuration };
|
|
||||||
} else if (motion.mode === "crab_recover" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + CRAGCLAW.repeatDelay, slashLanes: [], chargeHitIds: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
|
||||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.2, 14, context.damageMember);
|
|
||||||
return { boss, motion, party, events };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upcomingCragclawMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
|
||||||
if (motion.mode === "crab_scuttle_telegraph" || motion.mode === "crab_scuttling") return { name: "Sidewinder Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.scuttleWarning, urgent: true };
|
|
||||||
if (motion.mode === "crab_tidal_burst") return { name: "Crushing Tide — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.tidalWarning, urgent: true };
|
|
||||||
if (motion.mode === "crab_recover") return { name: "Cragclaw exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.recoverDuration, urgent: false };
|
|
||||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
|
||||||
return { name: motion.mechanicCount % 2 === 0 ? "Sidewinder Rush" : "Crushing Tide", remaining, cycle: CRAGCLAW.repeatDelay + CRAGCLAW.scuttleWarning, urgent: remaining < 2.5 };
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
|
||||||
import type { BossMotionState, BossState, MemberId } from "../types";
|
|
||||||
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
|
||||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
|
||||||
|
|
||||||
export const CROWNSHARD = {
|
|
||||||
firstAt: 5.5,
|
|
||||||
repeatDelay: 4,
|
|
||||||
shockwaveWarning: 1.2,
|
|
||||||
shockwaveInterval: 0.65,
|
|
||||||
shockwaveDamage: 19,
|
|
||||||
crownfallWarning: 1.5,
|
|
||||||
crownfallRadius: 1.85,
|
|
||||||
crownfallDamage: 28,
|
|
||||||
recoverDuration: 0.78,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const CROWNFALL_TARGETS: readonly (readonly MemberId[])[] = [
|
|
||||||
["aelia", "nia", "orin"],
|
|
||||||
["brann", "vale", "aelia"],
|
|
||||||
["nia", "orin", "vale"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createCrownshardState(): BossState {
|
|
||||||
const definition = BOSS_DEFINITIONS["crownshard-golem"];
|
|
||||||
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCrownshardMotion(): BossMotionState {
|
|
||||||
return { ...createBaseMotion("crownshard-golem"), position: [0, -6.7], nextMechanicAt: CROWNSHARD.firstAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
|
|
||||||
const mechanicCount = motion.mechanicCount + 1;
|
|
||||||
if (motion.mechanicCount % 2 === 0) {
|
|
||||||
const bands = [
|
|
||||||
{ innerRadius: 0, radius: 2.35 },
|
|
||||||
{ innerRadius: 2.35, radius: 4.7 },
|
|
||||||
{ innerRadius: 4.7, radius: 7.05 },
|
|
||||||
];
|
|
||||||
const firstActivation = context.time + CROWNSHARD.shockwaveWarning;
|
|
||||||
events.push({ at: context.time, message: "Tri-Burst expands in three rings. Move with each head's safe band.", tone: "danger", pulseKind: "boss" });
|
|
||||||
return {
|
|
||||||
...motion,
|
|
||||||
mode: "golem_shockwave" as const,
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: firstActivation + CROWNSHARD.shockwaveInterval * (bands.length - 1) + 0.32,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
mechanicCount,
|
|
||||||
hazards: [
|
|
||||||
...motion.hazards,
|
|
||||||
...bands.map((band, index) => createCircleHazard({
|
|
||||||
id: `crownshard-shockwave-${mechanicCount}-${index}`,
|
|
||||||
kind: "royal_shockwave",
|
|
||||||
center: motion.position,
|
|
||||||
innerRadius: band.innerRadius,
|
|
||||||
radius: band.radius,
|
|
||||||
activatesAt: firstActivation + index * CROWNSHARD.shockwaveInterval,
|
|
||||||
duration: 0.3,
|
|
||||||
damage: CROWNSHARD.shockwaveDamage,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const activatesAt = context.time + CROWNSHARD.crownfallWarning;
|
|
||||||
const targetSet = CROWNFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % CROWNFALL_TARGETS.length];
|
|
||||||
events.push({ at: context.time, message: "Ultimate Skyfall marks three allies. Break formation before impact.", tone: "danger", pulseKind: "skyfall", targetId: targetSet[0] });
|
|
||||||
return {
|
|
||||||
...motion,
|
|
||||||
mode: "golem_crownfall" as const,
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: activatesAt + 0.32,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
mechanicCount,
|
|
||||||
hazards: [
|
|
||||||
...motion.hazards,
|
|
||||||
...targetSet.map((targetId, index) => createCircleHazard({
|
|
||||||
id: `crownshard-fall-${mechanicCount}-${index}`,
|
|
||||||
kind: "crownfall",
|
|
||||||
center: context.partyPositions[targetId],
|
|
||||||
radius: CROWNSHARD.crownfallRadius,
|
|
||||||
activatesAt,
|
|
||||||
duration: 0.3,
|
|
||||||
damage: CROWNSHARD.crownfallDamage,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advanceCrownshardMechanics(context: BossMechanicContext): BossMechanicResult {
|
|
||||||
const boss = { ...context.boss };
|
|
||||||
let motion = cloneMotion(context.motion);
|
|
||||||
const events: BossMechanicResult["events"] = [];
|
|
||||||
let party = context.party;
|
|
||||||
|
|
||||||
if (motion.mode === "holding") {
|
|
||||||
returnBossToArenaCenter(motion, context.delta, 1.55);
|
|
||||||
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
|
|
||||||
} else if ((motion.mode === "golem_shockwave" || motion.mode === "golem_crownfall") && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "golem_recover", phaseEndsAt: context.time + CROWNSHARD.recoverDuration };
|
|
||||||
} else if (motion.mode === "golem_recover" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + CROWNSHARD.repeatDelay };
|
|
||||||
}
|
|
||||||
|
|
||||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
|
||||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.3, 15, context.damageMember);
|
|
||||||
return { boss, motion, party, events };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upcomingCrownshardMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
|
||||||
if (motion.mode === "golem_shockwave") return { name: "Tri-Burst — follow rings", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.shockwaveWarning + CROWNSHARD.shockwaveInterval * 2, urgent: true };
|
|
||||||
if (motion.mode === "golem_crownfall") return { name: "Ultimate Skyfall — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.crownfallWarning, urgent: true };
|
|
||||||
if (motion.mode === "golem_recover") return { name: "Ultimate Dragon exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.recoverDuration, urgent: false };
|
|
||||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
|
||||||
return { name: motion.mechanicCount % 2 === 0 ? "Tri-Burst" : "Ultimate Skyfall", remaining, cycle: CROWNSHARD.repeatDelay + CROWNSHARD.shockwaveWarning, urgent: remaining < 2.5 };
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { freshParty } from "../data";
|
|
||||||
import { pointToSegmentDistance } from "../geometry";
|
|
||||||
import { evadeSlashLanesBehavior } from "../partyBehaviors";
|
|
||||||
import type { BossMechanicContext } from "./types";
|
|
||||||
import {
|
|
||||||
advanceEmberMantisMechanics,
|
|
||||||
createEmberMantisMotion,
|
|
||||||
createEmberMantisState,
|
|
||||||
EMBER_MANTIS_SLASH,
|
|
||||||
} from "./emberMantis";
|
|
||||||
|
|
||||||
const POSITIONS: BossMechanicContext["partyPositions"] = {
|
|
||||||
aelia: [0, 4.5],
|
|
||||||
brann: [0, 0],
|
|
||||||
nia: [-3, 2],
|
|
||||||
orin: [3, 2],
|
|
||||||
vale: [0, -2],
|
|
||||||
};
|
|
||||||
|
|
||||||
function context(
|
|
||||||
boss: ReturnType<typeof createEmberMantisState>,
|
|
||||||
motion: ReturnType<typeof createEmberMantisMotion>,
|
|
||||||
party = freshParty(),
|
|
||||||
time = 0,
|
|
||||||
delta = 0.1,
|
|
||||||
): BossMechanicContext {
|
|
||||||
return {
|
|
||||||
boss,
|
|
||||||
motion,
|
|
||||||
party,
|
|
||||||
partyPositions: structuredClone(POSITIONS),
|
|
||||||
time,
|
|
||||||
delta,
|
|
||||||
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Warcaller mechanics", () => {
|
|
||||||
it("sidesteps, telegraphs Elemental Beam, then damages targets left in the lane", () => {
|
|
||||||
const boss = createEmberMantisState();
|
|
||||||
const sidestep = advanceEmberMantisMechanics(context(boss, createEmberMantisMotion(), freshParty(), 5, 0.1));
|
|
||||||
expect(sidestep.motion.mode).toBe("mantis_sidestep");
|
|
||||||
expect(sidestep.motion.chargeTargetId).toBe("nia");
|
|
||||||
|
|
||||||
const telegraph = advanceEmberMantisMechanics(context(sidestep.boss, sidestep.motion, sidestep.party, 5.6, 0.6));
|
|
||||||
expect(telegraph.motion.mode).toBe("mantis_line_telegraph");
|
|
||||||
expect(telegraph.motion.slashLanes).toHaveLength(1);
|
|
||||||
|
|
||||||
const niaBefore = telegraph.party.find((member) => member.id === "nia")!.hp;
|
|
||||||
const impact = advanceEmberMantisMechanics(context(telegraph.boss, telegraph.motion, telegraph.party, 6.51, 0.91));
|
|
||||||
expect(impact.motion.mode).toBe("mantis_recover");
|
|
||||||
expect(impact.motion.mechanicHitIds).toContain("nia");
|
|
||||||
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - EMBER_MANTIS_SLASH.lineDamage);
|
|
||||||
expect(impact.events.some((event) => event.message.includes("Elemental Beam"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("alternates into two crossed slash lanes", () => {
|
|
||||||
const motion = {
|
|
||||||
...createEmberMantisMotion(),
|
|
||||||
mode: "mantis_sidestep" as const,
|
|
||||||
mechanicCount: 1,
|
|
||||||
chargeTargetId: "orin" as const,
|
|
||||||
chargeEnd: [0, -6.6] as [number, number],
|
|
||||||
phaseEndsAt: 0,
|
|
||||||
};
|
|
||||||
const result = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1));
|
|
||||||
expect(result.motion.mode).toBe("mantis_cross_telegraph");
|
|
||||||
expect(result.motion.slashLanes).toHaveLength(2);
|
|
||||||
expect(result.motion.slashLanes[0].id).toContain("cross");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives mobile allies a target outside crossed lanes", () => {
|
|
||||||
const motion = {
|
|
||||||
...createEmberMantisMotion(),
|
|
||||||
mode: "mantis_sidestep" as const,
|
|
||||||
mechanicCount: 1,
|
|
||||||
chargeTargetId: "orin" as const,
|
|
||||||
chargeEnd: [0, -6.6] as [number, number],
|
|
||||||
phaseEndsAt: 0,
|
|
||||||
};
|
|
||||||
const telegraph = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1)).motion;
|
|
||||||
const decision = evadeSlashLanesBehavior.decide({
|
|
||||||
memberId: "orin",
|
|
||||||
current: POSITIONS.orin,
|
|
||||||
formationTarget: POSITIONS.orin,
|
|
||||||
bossMotion: telegraph,
|
|
||||||
partyPositions: POSITIONS,
|
|
||||||
time: 1,
|
|
||||||
});
|
|
||||||
expect(decision).not.toBeNull();
|
|
||||||
const minimumLaneDistance = Math.min(...telegraph.slashLanes.map((lane) =>
|
|
||||||
pointToSegmentDistance(decision!.target, lane.start, lane.end),
|
|
||||||
));
|
|
||||||
expect(minimumLaneDistance).toBeGreaterThan(EMBER_MANTIS_SLASH.aiClearance);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
|
||||||
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
|
|
||||||
import type { BossId, BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
|
||||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, returnBossToArenaCenter } from "./shared";
|
|
||||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
|
||||||
|
|
||||||
export const EMBER_MANTIS_SLASH = {
|
|
||||||
firstAt: 5,
|
|
||||||
repeatDelay: 3.4,
|
|
||||||
sidestepDuration: 0.55,
|
|
||||||
sidestepDistance: 3.8,
|
|
||||||
sidestepSpeed: 7.2,
|
|
||||||
telegraphDuration: 0.9,
|
|
||||||
recoverDuration: 0.7,
|
|
||||||
laneLength: 18,
|
|
||||||
lineWidth: 1.65,
|
|
||||||
crossWidth: 1.45,
|
|
||||||
lineDamage: 32,
|
|
||||||
crossDamage: 25,
|
|
||||||
crossAngle: Math.PI * 0.18,
|
|
||||||
staggerDuration: 0.35,
|
|
||||||
aiClearance: 1.35,
|
|
||||||
aiEvadeSpeed: 4.8,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
|
|
||||||
const MIN_BOSS_X = -5.8;
|
|
||||||
const MAX_BOSS_X = 5.8;
|
|
||||||
|
|
||||||
export function createEmberMantisState(bossId: BossId = "warcaller-orc"): BossState {
|
|
||||||
const definition = BOSS_DEFINITIONS[bossId];
|
|
||||||
return {
|
|
||||||
id: definition.id,
|
|
||||||
name: definition.name,
|
|
||||||
maxHp: definition.maxHp,
|
|
||||||
hp: definition.maxHp,
|
|
||||||
nextMeleeAt: 2.2,
|
|
||||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
|
||||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
|
||||||
brandCount: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEmberMantisMotion(bossId: BossId = "warcaller-orc"): BossMotionState {
|
|
||||||
return {
|
|
||||||
...createBaseMotion(bossId),
|
|
||||||
position: [0, -6.6],
|
|
||||||
nextMechanicAt: EMBER_MANTIS_SLASH.firstAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampBossX(value: number) {
|
|
||||||
return Math.max(MIN_BOSS_X, Math.min(MAX_BOSS_X, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLane(
|
|
||||||
id: string,
|
|
||||||
center: WorldPosition,
|
|
||||||
angle: number,
|
|
||||||
width: number,
|
|
||||||
damage: number,
|
|
||||||
): SlashLane {
|
|
||||||
const halfLength = EMBER_MANTIS_SLASH.laneLength * 0.5;
|
|
||||||
const dx = Math.sin(angle) * halfLength;
|
|
||||||
const dz = Math.cos(angle) * halfLength;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
start: [center[0] - dx, center[1] - dz],
|
|
||||||
end: [center[0] + dx, center[1] + dz],
|
|
||||||
width,
|
|
||||||
damage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginSidestep(
|
|
||||||
motion: BossMotionState,
|
|
||||||
party: BossMechanicContext["party"],
|
|
||||||
partyPositions: BossMechanicContext["partyPositions"],
|
|
||||||
time: number,
|
|
||||||
) {
|
|
||||||
const targetId = chooseLivingTarget(party, TARGET_ORDER, motion.mechanicCount);
|
|
||||||
const direction = motion.mechanicCount % 2 === 0 ? 1 : -1;
|
|
||||||
let targetX = clampBossX(motion.position[0] + direction * EMBER_MANTIS_SLASH.sidestepDistance);
|
|
||||||
if (Math.abs(targetX - motion.position[0]) < 1) {
|
|
||||||
targetX = clampBossX(motion.position[0] - direction * EMBER_MANTIS_SLASH.sidestepDistance);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...motion,
|
|
||||||
mode: "mantis_sidestep" as const,
|
|
||||||
chargeTargetId: targetId,
|
|
||||||
chargeEnd: [targetX, motion.position[1]] as WorldPosition,
|
|
||||||
phaseStartedAt: time,
|
|
||||||
phaseEndsAt: time + EMBER_MANTIS_SLASH.sidestepDuration,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
slashLanes: [],
|
|
||||||
mechanicHitIds: [],
|
|
||||||
pounceCenter: [partyPositions[targetId][0], partyPositions[targetId][1]] as WorldPosition,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginSlashTelegraph(
|
|
||||||
motion: BossMotionState,
|
|
||||||
partyPositions: BossMechanicContext["partyPositions"],
|
|
||||||
time: number,
|
|
||||||
) {
|
|
||||||
const target = partyPositions[motion.chargeTargetId];
|
|
||||||
const aimedAngle = angleTo(motion.position, target);
|
|
||||||
const isCrossSlash = motion.mechanicCount % 2 === 1;
|
|
||||||
const slashNumber = motion.mechanicCount + 1;
|
|
||||||
const lanes = isCrossSlash
|
|
||||||
? [
|
|
||||||
createLane(`cross-${slashNumber}-left`, target, aimedAngle - EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
|
|
||||||
createLane(`cross-${slashNumber}-right`, target, aimedAngle + EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
|
|
||||||
]
|
|
||||||
: [createLane(`line-${slashNumber}`, target, aimedAngle, EMBER_MANTIS_SLASH.lineWidth, EMBER_MANTIS_SLASH.lineDamage)];
|
|
||||||
|
|
||||||
return {
|
|
||||||
...motion,
|
|
||||||
mode: isCrossSlash ? "mantis_cross_telegraph" as const : "mantis_line_telegraph" as const,
|
|
||||||
phaseStartedAt: time,
|
|
||||||
phaseEndsAt: time + EMBER_MANTIS_SLASH.telegraphDuration,
|
|
||||||
mechanicCount: slashNumber,
|
|
||||||
slashLanes: lanes,
|
|
||||||
mechanicHitIds: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSlash(
|
|
||||||
motion: BossMotionState,
|
|
||||||
context: BossMechanicContext,
|
|
||||||
events: BossMechanicResult["events"],
|
|
||||||
) {
|
|
||||||
const isCrossSlash = motion.mode === "mantis_cross_telegraph";
|
|
||||||
const hitIds: MemberId[] = [];
|
|
||||||
const party = context.party.map((member) => {
|
|
||||||
if (member.hp <= 0) return member;
|
|
||||||
const lane = motion.slashLanes.find((candidate) =>
|
|
||||||
pointToSegmentDistance(context.partyPositions[member.id], candidate.start, candidate.end) <= candidate.width * 0.5,
|
|
||||||
);
|
|
||||||
if (!lane) return member;
|
|
||||||
hitIds.push(member.id);
|
|
||||||
events.push({
|
|
||||||
at: context.time,
|
|
||||||
message: `${member.name} is caught by ${isCrossSlash ? "Guardian Cross" : "Elemental Beam"}.`,
|
|
||||||
tone: "danger",
|
|
||||||
pulseKind: "slash",
|
|
||||||
targetId: member.id,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time),
|
|
||||||
knockedUntil: Math.max(member.knockedUntil, context.time + EMBER_MANTIS_SLASH.staggerDuration),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return { party, hitIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advanceEmberMantisMechanics(context: BossMechanicContext): BossMechanicResult {
|
|
||||||
const boss = { ...context.boss };
|
|
||||||
let motion = cloneMotion(context.motion);
|
|
||||||
let party = context.party;
|
|
||||||
const events: BossMechanicResult["events"] = [];
|
|
||||||
|
|
||||||
if (motion.mode === "holding") {
|
|
||||||
returnBossToArenaCenter(motion, context.delta, 2.5);
|
|
||||||
if (context.time >= motion.nextMechanicAt) {
|
|
||||||
motion = beginSidestep(motion, party, context.partyPositions, context.time);
|
|
||||||
events.push({
|
|
||||||
at: context.time,
|
|
||||||
message: `${boss.name} shifts toward ${memberName(party, motion.chargeTargetId)}. Track its attack.`,
|
|
||||||
tone: "danger",
|
|
||||||
pulseKind: "slash",
|
|
||||||
targetId: motion.chargeTargetId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "mantis_sidestep") {
|
|
||||||
motion.position = moveToward(motion.position, motion.chargeEnd, EMBER_MANTIS_SLASH.sidestepSpeed * context.delta);
|
|
||||||
if (context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = beginSlashTelegraph(motion, context.partyPositions, context.time);
|
|
||||||
const cross = motion.mode === "mantis_cross_telegraph";
|
|
||||||
events.push({
|
|
||||||
at: context.time,
|
|
||||||
message: cross ? "Guardian Cross! Find a safe quadrant." : "Elemental Beam! Clear the glowing lane.",
|
|
||||||
tone: "danger",
|
|
||||||
pulseKind: "slash",
|
|
||||||
targetId: motion.chargeTargetId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "mantis_line_telegraph" || motion.mode === "mantis_cross_telegraph") {
|
|
||||||
if (context.time >= motion.phaseEndsAt) {
|
|
||||||
const resolved = resolveSlash(motion, context, events);
|
|
||||||
party = resolved.party;
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "mantis_recover",
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: context.time + EMBER_MANTIS_SLASH.recoverDuration,
|
|
||||||
mechanicHitIds: resolved.hitIds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "mantis_recover" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "holding",
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: 0,
|
|
||||||
nextMechanicAt: context.time + EMBER_MANTIS_SLASH.repeatDelay,
|
|
||||||
slashLanes: [],
|
|
||||||
mechanicHitIds: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 1.9, 14, context.damageMember);
|
|
||||||
return { boss, motion, party, events };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upcomingEmberMantisMechanic(
|
|
||||||
boss: BossState,
|
|
||||||
motion: BossMotionState,
|
|
||||||
time: number,
|
|
||||||
): UpcomingMechanic {
|
|
||||||
if (motion.mode === "mantis_sidestep") {
|
|
||||||
return { name: "Guardian repositioning", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.sidestepDuration, urgent: true };
|
|
||||||
}
|
|
||||||
if (motion.mode === "mantis_line_telegraph") {
|
|
||||||
return { name: "Elemental Beam — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
|
|
||||||
}
|
|
||||||
if (motion.mode === "mantis_cross_telegraph") {
|
|
||||||
return { name: "Guardian Cross — safe quadrant", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
|
|
||||||
}
|
|
||||||
if (motion.mode === "mantis_recover") {
|
|
||||||
return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.recoverDuration, urgent: false };
|
|
||||||
}
|
|
||||||
const nextIsCross = motion.mechanicCount % 2 === 1;
|
|
||||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
|
||||||
return {
|
|
||||||
name: nextIsCross ? "Guardian Cross" : "Elemental Beam",
|
|
||||||
remaining,
|
|
||||||
cycle: EMBER_MANTIS_SLASH.repeatDelay + EMBER_MANTIS_SLASH.telegraphDuration,
|
|
||||||
urgent: remaining < 2.5,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { freshParty } from "../data";
|
|
||||||
import type { BossMotionState, BossState, WorldPosition } from "../types";
|
|
||||||
import { advanceCinderbackMechanics, CINDERBACK, createCinderbackMotion, createCinderbackState } from "./ricochet";
|
|
||||||
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, OBSIDIAN_RAM } from "./obsidianRamGolem";
|
|
||||||
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, SANDGLASS } from "./sandglassScorpion";
|
|
||||||
import type { BossMechanicContext } from "./types";
|
|
||||||
|
|
||||||
const POSITIONS: BossMechanicContext["partyPositions"] = {
|
|
||||||
aelia: [0, 4.5],
|
|
||||||
brann: [0, 0],
|
|
||||||
nia: [-3, 2],
|
|
||||||
orin: [3, 2],
|
|
||||||
vale: [0, -2],
|
|
||||||
};
|
|
||||||
|
|
||||||
function context(
|
|
||||||
boss: BossState,
|
|
||||||
motion: BossMotionState,
|
|
||||||
time: number,
|
|
||||||
delta = 0.1,
|
|
||||||
positions = POSITIONS,
|
|
||||||
party = freshParty(),
|
|
||||||
): BossMechanicContext {
|
|
||||||
return {
|
|
||||||
boss,
|
|
||||||
motion,
|
|
||||||
party,
|
|
||||||
partyPositions: structuredClone(positions),
|
|
||||||
time,
|
|
||||||
delta,
|
|
||||||
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("IWT2 boss trio mechanics", () => {
|
|
||||||
it("telegraphs and resolves Bristlequake Tusk Charge", () => {
|
|
||||||
const start = advanceObsidianRamMechanics(context(createObsidianRamState(), createObsidianRamMotion(), OBSIDIAN_RAM.firstAt));
|
|
||||||
expect(start.motion.mode).toBe("ram_charge_telegraph");
|
|
||||||
expect(start.motion.slashLanes).toHaveLength(1);
|
|
||||||
|
|
||||||
const active = advanceObsidianRamMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
|
|
||||||
expect(active.motion.mode).toBe("ram_charging");
|
|
||||||
const niaBefore = active.party.find((member) => member.id === "nia")!.hp;
|
|
||||||
const impact = advanceObsidianRamMechanics(context(active.boss, active.motion, active.motion.phaseStartedAt + 1, 1, POSITIONS, active.party));
|
|
||||||
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - OBSIDIAN_RAM.chargeDamage);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds three Armor Shatter fault lanes", () => {
|
|
||||||
const motion = { ...createObsidianRamMotion(), mechanicCount: 2, nextMechanicAt: 0 };
|
|
||||||
const result = advanceObsidianRamMechanics(context(createObsidianRamState(), motion, 0));
|
|
||||||
expect(result.motion.mode).toBe("ram_shatter");
|
|
||||||
expect(result.motion.slashLanes).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("executes both Cinderback rebounds and leaves lava at each impact", () => {
|
|
||||||
const start = advanceCinderbackMechanics(context(createCinderbackState(), createCinderbackMotion(), CINDERBACK.firstAt));
|
|
||||||
const firstRush = advanceCinderbackMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
|
|
||||||
const firstImpact = advanceCinderbackMechanics(context(firstRush.boss, firstRush.motion, firstRush.motion.phaseStartedAt + 2, 2, POSITIONS, firstRush.party));
|
|
||||||
expect(firstImpact.motion.mode).toBe("cinderback_ricochet");
|
|
||||||
expect(firstImpact.motion.chargeCount).toBe(1);
|
|
||||||
expect(firstImpact.motion.hazards.filter((hazard) => hazard.kind === "lava_pool")).toHaveLength(1);
|
|
||||||
|
|
||||||
const secondImpact = advanceCinderbackMechanics(context(firstImpact.boss, firstImpact.motion, firstImpact.motion.phaseEndsAt, 2, POSITIONS, firstImpact.party));
|
|
||||||
expect(secondImpact.motion.mode).toBe("cinderback_recover");
|
|
||||||
expect(secondImpact.motion.hazards.filter((hazard) => hazard.kind === "lava_pool")).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("turns Sandglass stinger warnings into an active hourglass zone", () => {
|
|
||||||
const motion = { ...createSandglassMotion(), mechanicCount: 1, nextMechanicAt: 0 };
|
|
||||||
const start = advanceSandglassMechanics(context(createSandglassState(), motion, 0));
|
|
||||||
expect(start.motion.mode).toBe("sandglass_eruption");
|
|
||||||
expect(start.motion.hazards.filter((hazard) => hazard.kind === "stinger_eruption")).toHaveLength(3);
|
|
||||||
|
|
||||||
const positions = structuredClone(POSITIONS);
|
|
||||||
positions.aelia = [...start.motion.hazards[0].center] as WorldPosition;
|
|
||||||
const aeliaBefore = start.party[0].hp;
|
|
||||||
const eruption = advanceSandglassMechanics(context(start.boss, start.motion, SANDGLASS.eruptionWarning + 0.05, 0.1, positions, start.party));
|
|
||||||
expect(eruption.party[0].hp).toBe(aeliaBefore - SANDGLASS.eruptionDamage);
|
|
||||||
|
|
||||||
const hourglass = advanceSandglassMechanics(context(eruption.boss, eruption.motion, eruption.motion.phaseEndsAt + 0.01, 0.1, POSITIONS, eruption.party));
|
|
||||||
expect(hourglass.motion.mode).toBe("sandglass_hourglass");
|
|
||||||
expect(hourglass.motion.hazards.some((hazard) => hazard.kind === "hourglass")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { freshParty } from "../data";
|
import { freshParty } from "../data";
|
||||||
import { createBaseMotion } from "./shared";
|
import { createBaseMotion } from "./shared";
|
||||||
import { advancePooledBossMechanics, POOLED_MECHANIC_TIMING, SOUL_SIPHON } from "./mechanicPool";
|
import { advanceMechanicLoadout, BOSS_MECHANIC_POOL, bossAnimationCue, SOUL_SIPHON } from "./mechanicPool";
|
||||||
import type { BossMechanicContext, BossMechanicResult } from "./types";
|
import type { BossMechanicContext } from "./types";
|
||||||
import type { BossState, PoolTelegraph, WorldPosition } from "../types";
|
import type { BossState, PoolTelegraph, WorldPosition } from "../types";
|
||||||
|
|
||||||
const POSITIONS: BossMechanicContext["partyPositions"] = {
|
const POSITIONS: BossMechanicContext["partyPositions"] = {
|
||||||
@@ -20,9 +20,6 @@ function state(): BossState {
|
|||||||
maxHp: 500,
|
maxHp: 500,
|
||||||
hp: 500,
|
hp: 500,
|
||||||
nextMeleeAt: Number.POSITIVE_INFINITY,
|
nextMeleeAt: Number.POSITIVE_INFINITY,
|
||||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
|
||||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
|
||||||
brandCount: 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,10 +35,6 @@ function context(time: number, positions = POSITIONS): BossMechanicContext {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function result(contextValue: BossMechanicContext): BossMechanicResult {
|
|
||||||
return { boss: contextValue.boss, motion: contextValue.motion, party: contextValue.party, events: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function memoryTelegraph(): PoolTelegraph {
|
function memoryTelegraph(): PoolTelegraph {
|
||||||
return {
|
return {
|
||||||
id: "test-memory",
|
id: "test-memory",
|
||||||
@@ -79,10 +72,10 @@ function advanceMemory(
|
|||||||
source.party = party;
|
source.party = party;
|
||||||
source.motion = {
|
source.motion = {
|
||||||
...source.motion,
|
...source.motion,
|
||||||
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
|
activeMechanicId: "memory-sequence",
|
||||||
poolTelegraphs: [telegraph],
|
poolTelegraphs: [telegraph],
|
||||||
};
|
};
|
||||||
return advancePooledBossMechanics(source, result(source));
|
return advanceMechanicLoadout(source, ["memory-sequence", "bull-charge"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function soulSiphonTelegraph(): PoolTelegraph {
|
function soulSiphonTelegraph(): PoolTelegraph {
|
||||||
@@ -121,21 +114,31 @@ function advanceSoulSiphon(
|
|||||||
source.party = party;
|
source.party = party;
|
||||||
source.motion = {
|
source.motion = {
|
||||||
...source.motion,
|
...source.motion,
|
||||||
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
|
activeMechanicId: "soul-siphon",
|
||||||
poolTelegraphs: [telegraph],
|
poolTelegraphs: [telegraph],
|
||||||
};
|
};
|
||||||
return advancePooledBossMechanics(source, result(source));
|
return advanceMechanicLoadout(source, ["soul-siphon", "bull-charge"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("shared boss mechanic pool", () => {
|
describe("shared boss mechanic pool", () => {
|
||||||
it("schedules a telegraphed pool mechanic and keeps its warning state independent of boss mode", () => {
|
it.each(BOSS_MECHANIC_POOL.filter(({ id }) => id !== "basic-melee"))("starts $name directly from its canonical ID", ({ id }) => {
|
||||||
const source = context(POOLED_MECHANIC_TIMING.firstAt);
|
const source = context(0);
|
||||||
const started = advancePooledBossMechanics(source, result(source));
|
source.motion.nextMechanicAt = 0;
|
||||||
|
const started = advanceMechanicLoadout(source, [id, id]);
|
||||||
|
|
||||||
expect(started.motion.mode).toBe("holding");
|
expect(started.motion.activeMechanicId).toBe(id);
|
||||||
|
expect(["idle", "move", "attack", "special"]).toContain(bossAnimationCue(started.motion));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules the exact mechanic ID selected by a boss loadout", () => {
|
||||||
|
const source = context(0);
|
||||||
|
source.motion.nextMechanicAt = 0;
|
||||||
|
const started = advanceMechanicLoadout(source, ["meteor-spread", "bull-charge"]);
|
||||||
|
|
||||||
|
expect(started.motion.activeMechanicId).toBe("meteor-spread");
|
||||||
expect(started.motion.poolTelegraphs).not.toHaveLength(0);
|
expect(started.motion.poolTelegraphs).not.toHaveLength(0);
|
||||||
expect(started.events[0].message).toContain(":");
|
expect(started.events[0].message).toContain(":");
|
||||||
expect(started.motion.nextPoolMechanicAt).toBe(Number.POSITIVE_INFINITY);
|
expect(bossAnimationCue(started.motion)).toBe("attack");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("splits soak damage across allies inside its indicator", () => {
|
it("splits soak damage across allies inside its indicator", () => {
|
||||||
@@ -156,14 +159,14 @@ describe("shared boss mechanic pool", () => {
|
|||||||
resolved: false,
|
resolved: false,
|
||||||
hitIds: [],
|
hitIds: [],
|
||||||
};
|
};
|
||||||
const motion = { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [soak] };
|
const motion = { ...source.motion, activeMechanicId: "aetheric-soak" as const, poolTelegraphs: [soak] };
|
||||||
const positions = structuredClone(POSITIONS);
|
const positions = structuredClone(POSITIONS);
|
||||||
positions.aelia = [0.2, 0];
|
positions.aelia = [0.2, 0];
|
||||||
positions.brann = [0, 0];
|
positions.brann = [0, 0];
|
||||||
positions.nia = [-0.2, 0];
|
positions.nia = [-0.2, 0];
|
||||||
positions.vale = [0, -4];
|
positions.vale = [0, -4];
|
||||||
const atImpact = { ...source, motion, partyPositions: positions, time: activatesAt };
|
const atImpact = { ...source, motion, partyPositions: positions, time: activatesAt };
|
||||||
const resolved = advancePooledBossMechanics(atImpact, result(atImpact));
|
const resolved = advanceMechanicLoadout(atImpact, ["aetheric-soak", "bull-charge"]);
|
||||||
|
|
||||||
expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp - 30);
|
expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp - 30);
|
||||||
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 30);
|
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 30);
|
||||||
@@ -192,11 +195,11 @@ describe("shared boss mechanic pool", () => {
|
|||||||
positions.brann = [3, 0];
|
positions.brann = [3, 0];
|
||||||
const atImpact = {
|
const atImpact = {
|
||||||
...source,
|
...source,
|
||||||
motion: { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [donut] },
|
motion: { ...source.motion, activeMechanicId: "hollow-collapse" as const, poolTelegraphs: [donut] },
|
||||||
partyPositions: positions,
|
partyPositions: positions,
|
||||||
time: activatesAt,
|
time: activatesAt,
|
||||||
};
|
};
|
||||||
const resolved = advancePooledBossMechanics(atImpact, result(atImpact));
|
const resolved = advanceMechanicLoadout(atImpact, ["hollow-collapse", "bull-charge"]);
|
||||||
|
|
||||||
expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp);
|
expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp);
|
||||||
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 25);
|
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 25);
|
||||||
@@ -245,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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+777
-71
@@ -1,9 +1,34 @@
|
|||||||
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
|
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
|
||||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
||||||
import type { BossMotionState, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, WorldPosition } from "../types";
|
import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types";
|
||||||
|
import { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
||||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||||
|
|
||||||
export const BOSS_MECHANIC_POOL = [
|
export const BOSS_MECHANIC_POOL = [
|
||||||
|
{ id: "basic-melee", name: "Basic Melee", instruction: "Maintain tank pressure." },
|
||||||
|
{ id: "bull-charge", name: "Bull Charge", instruction: "Clear the marked charge lane." },
|
||||||
|
{ id: "crushing-pounce", name: "Crushing Pounce", instruction: "Stack to split the impact." },
|
||||||
|
{ id: "cinder-nova", name: "Cinder Nova", instruction: "Heal the party through raidwide damage." },
|
||||||
|
{ id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." },
|
||||||
|
{ id: "binding-web", name: "Binding Web", instruction: "Separate the linked allies." },
|
||||||
|
{ id: "venom-purge", name: "Venom Purge", instruction: "Move away before cleansing Widow Venom." },
|
||||||
|
{ id: "storm-breath", name: "Storm Breath", instruction: "Rotate behind the sweeping cone." },
|
||||||
|
{ id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." },
|
||||||
|
{ id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." },
|
||||||
|
{ id: "guardian-cross", name: "Guardian Cross", instruction: "Find a safe quadrant." },
|
||||||
|
{ id: "destruction-rush", name: "Destruction Rush", instruction: "Clear the marked rush lane." },
|
||||||
|
{ id: "ruin-quake", name: "Ruin Quake", instruction: "Leave the destruction circle." },
|
||||||
|
{ id: "destruction-pulse", name: "Destruction Pulse", instruction: "Step between the radial beams." },
|
||||||
|
{ id: "ricochet-rush", name: "Ricochet Rush", instruction: "Dodge both rebound lanes." },
|
||||||
|
{ id: "meteor-slam", name: "Meteor Slam", instruction: "Leave the impact and spreading flame." },
|
||||||
|
{ id: "burrow-rush", name: "Burrow Rush", instruction: "Cross the marked trail." },
|
||||||
|
{ id: "hourglass-eruption", name: "Hourglass Eruption", instruction: "Leave the eruptions and moving zone." },
|
||||||
|
{ id: "sidewinder-rush", name: "Sidewinder Rush", instruction: "Clear the surf lane." },
|
||||||
|
{ id: "crushing-tide", name: "Crushing Tide", instruction: "Spread before the claws close." },
|
||||||
|
{ id: "vine-scissors", name: "Vine Scissors", instruction: "Dodge the first and rotated crosses." },
|
||||||
|
{ id: "haunting-rifts", name: "Haunting Rifts", instruction: "Carry persistent rifts away from formation." },
|
||||||
|
{ id: "tri-burst", name: "Tri-Burst", instruction: "Move through the three expanding rings." },
|
||||||
|
{ id: "ultimate-skyfall", name: "Ultimate Skyfall", instruction: "Spread before the marked impacts." },
|
||||||
{
|
{
|
||||||
id: "meteor-spread",
|
id: "meteor-spread",
|
||||||
name: "Meteor Spread",
|
name: "Meteor Spread",
|
||||||
@@ -36,18 +61,62 @@ export const BOSS_MECHANIC_POOL = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type PoolMechanicId = (typeof BOSS_MECHANIC_POOL)[number]["id"];
|
const MECHANIC_COPY_BY_ID = Object.fromEntries(
|
||||||
|
BOSS_MECHANIC_POOL.map((mechanic) => [mechanic.id, mechanic]),
|
||||||
|
) as Record<BossMechanicId, (typeof BOSS_MECHANIC_POOL)[number]>;
|
||||||
|
|
||||||
|
export function bossMechanicName(id: BossMechanicId) {
|
||||||
|
return MECHANIC_COPY_BY_ID[id].name;
|
||||||
|
}
|
||||||
|
|
||||||
export const POOLED_MECHANIC_TIMING = {
|
export const POOLED_MECHANIC_TIMING = {
|
||||||
firstAt: 15,
|
|
||||||
repeatDelay: 26,
|
|
||||||
activeDuration: 0.42,
|
activeDuration: 0.42,
|
||||||
warningDuration: 1.4,
|
warningDuration: 1.4,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const BULL_CHARGE = {
|
||||||
|
warning: 1.8,
|
||||||
|
speed: 10.5,
|
||||||
|
distance: 13.5,
|
||||||
|
hitRadius: 1.35,
|
||||||
|
damage: 18,
|
||||||
|
knockdown: 0.75,
|
||||||
|
cooldown: 4,
|
||||||
|
aiClearance: 1.9,
|
||||||
|
aiEvadeSpeed: 3.4,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BULL_POUNCE = {
|
||||||
|
stackDuration: 5,
|
||||||
|
stackRadius: 2.2,
|
||||||
|
sharedDamage: 200,
|
||||||
|
leapDuration: 0.55,
|
||||||
|
cooldown: 4,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const SKY_SWEEPER_BREATH = {
|
||||||
|
telegraphDuration: 2,
|
||||||
|
sweepDuration: 3.2,
|
||||||
|
range: 10.5,
|
||||||
|
halfAngle: Math.PI / 7,
|
||||||
|
sweepArc: Math.PI * 0.95,
|
||||||
|
tickDamage: 9,
|
||||||
|
tickInterval: 0.45,
|
||||||
|
cooldown: 4,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const VENOM_PURGE = {
|
||||||
|
duration: 10,
|
||||||
|
tickDamage: 5,
|
||||||
|
castDuration: 2.5,
|
||||||
|
poolRadius: 2,
|
||||||
|
poolDuration: 7,
|
||||||
|
poolDamage: 14,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const MEMORY_SEQUENCE = {
|
export const MEMORY_SEQUENCE = {
|
||||||
sequenceLength: 4,
|
sequenceLength: 4,
|
||||||
flashDuration: 0.68,
|
flashDuration: 0.9,
|
||||||
inputDuration: 7,
|
inputDuration: 7,
|
||||||
tileSize: 2.15,
|
tileSize: 2.15,
|
||||||
raidwideDamage: 15,
|
raidwideDamage: 15,
|
||||||
@@ -58,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 }> = {
|
||||||
@@ -84,13 +153,6 @@ const MEMORY_SEQUENCES: readonly (readonly MemorySymbolId[])[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "brann", "aelia"];
|
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "brann", "aelia"];
|
||||||
|
|
||||||
function bossPoolOffset(bossId: string) {
|
|
||||||
let value = 0;
|
|
||||||
for (let index = 0; index < bossId.length; index += 1) value = (value + bossId.charCodeAt(index)) % BOSS_MECHANIC_POOL.length;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function liveTarget(party: PartyMember[], targetId: MemberId) {
|
function liveTarget(party: PartyMember[], targetId: MemberId) {
|
||||||
return party.some((member) => member.id === targetId && member.hp > 0)
|
return party.some((member) => member.id === targetId && member.hp > 0)
|
||||||
? targetId
|
? targetId
|
||||||
@@ -220,9 +282,10 @@ function beginPoolMechanic(
|
|||||||
party: PartyMember[],
|
party: PartyMember[],
|
||||||
positions: BossMechanicContext["partyPositions"],
|
positions: BossMechanicContext["partyPositions"],
|
||||||
time: number,
|
time: number,
|
||||||
|
requestedId: BossMechanicId,
|
||||||
) {
|
) {
|
||||||
const count = motion.poolMechanicCount + 1;
|
const count = motion.poolMechanicCount + 1;
|
||||||
const entry = BOSS_MECHANIC_POOL[(bossPoolOffset(motion.bossId) + motion.poolMechanicCount) % BOSS_MECHANIC_POOL.length];
|
const entry = MECHANIC_COPY_BY_ID[requestedId];
|
||||||
const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration;
|
const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration;
|
||||||
let telegraphs: PoolTelegraph[];
|
let telegraphs: PoolTelegraph[];
|
||||||
let targetId: MemberId | undefined;
|
let targetId: MemberId | undefined;
|
||||||
@@ -281,7 +344,6 @@ function beginPoolMechanic(
|
|||||||
motion: {
|
motion: {
|
||||||
...motion,
|
...motion,
|
||||||
poolMechanicCount: count,
|
poolMechanicCount: count,
|
||||||
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
poolTelegraphs: telegraphs,
|
poolTelegraphs: telegraphs,
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
@@ -498,61 +560,6 @@ function resolveTelegraph(
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Composes the shared mechanic pool after a boss's signature mechanic update. */
|
|
||||||
export function advancePooledBossMechanics(
|
|
||||||
context: BossMechanicContext,
|
|
||||||
result: BossMechanicResult,
|
|
||||||
): BossMechanicResult {
|
|
||||||
if (context.allowPooledMechanics === false) return result;
|
|
||||||
const source = result.motion;
|
|
||||||
if (!source.poolTelegraphs.length && context.time < source.nextPoolMechanicAt) return result;
|
|
||||||
|
|
||||||
let motion: BossMotionState = {
|
|
||||||
...source,
|
|
||||||
poolTelegraphs: source.poolTelegraphs.map((telegraph) => ({
|
|
||||||
...telegraph,
|
|
||||||
center: [telegraph.center[0], telegraph.center[1]],
|
|
||||||
start: telegraph.start && [telegraph.start[0], telegraph.start[1]],
|
|
||||||
end: telegraph.end && [telegraph.end[0], telegraph.end[1]],
|
|
||||||
tiles: telegraph.tiles?.map((tile) => ({ ...tile, center: [tile.center[0], tile.center[1]] })),
|
|
||||||
soulSiphon: telegraph.soulSiphon && {
|
|
||||||
...telegraph.soulSiphon,
|
|
||||||
ghostPosition: [telegraph.soulSiphon.ghostPosition[0], telegraph.soulSiphon.ghostPosition[1]],
|
|
||||||
wardPosition: [telegraph.soulSiphon.wardPosition[0], telegraph.soulSiphon.wardPosition[1]],
|
|
||||||
},
|
|
||||||
hitIds: [...telegraph.hitIds],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
let party = result.party;
|
|
||||||
const events = [...result.events];
|
|
||||||
|
|
||||||
for (const telegraph of motion.poolTelegraphs) {
|
|
||||||
if (telegraph.kind === "memory") {
|
|
||||||
if (!telegraph.resolved) party = resolveMemorySequence(telegraph, party, context.partyPositions, context, events);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (telegraph.kind === "soul-siphon") {
|
|
||||||
if (!telegraph.resolved) party = resolveSoulSiphon(telegraph, party, context.partyPositions, context, events);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (telegraph.resolved || context.time < telegraph.activatesAt) continue;
|
|
||||||
party = resolveTelegraph(telegraph, party, context.partyPositions, context, events);
|
|
||||||
telegraph.resolved = true;
|
|
||||||
}
|
|
||||||
motion.poolTelegraphs = motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > context.time);
|
|
||||||
|
|
||||||
if (!motion.poolTelegraphs.length && context.time >= motion.nextPoolMechanicAt) {
|
|
||||||
const started = beginPoolMechanic(motion, party, context.partyPositions, context.time);
|
|
||||||
motion = started.motion;
|
|
||||||
events.push(started.event);
|
|
||||||
}
|
|
||||||
if (!motion.poolTelegraphs.length && !Number.isFinite(motion.nextPoolMechanicAt)) {
|
|
||||||
motion.nextPoolMechanicAt = context.time + POOLED_MECHANIC_TIMING.repeatDelay;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...result, motion, party, events };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upcomingPooledMechanic(motion: BossMotionState, time: number): UpcomingMechanic | null {
|
export function upcomingPooledMechanic(motion: BossMotionState, time: number): UpcomingMechanic | null {
|
||||||
const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time);
|
const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time);
|
||||||
if (!telegraphs.length) return null;
|
if (!telegraphs.length) return null;
|
||||||
@@ -582,3 +589,702 @@ export function upcomingPooledMechanic(motion: BossMotionState, time: number): U
|
|||||||
urgent: true,
|
urgent: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MechanicRuntime {
|
||||||
|
readonly context: BossMechanicContext;
|
||||||
|
boss: BossMechanicResult["boss"];
|
||||||
|
motion: BossMotionState;
|
||||||
|
party: PartyMember[];
|
||||||
|
events: BossMechanicResult["events"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BossMechanicDefinition {
|
||||||
|
readonly id: BossMechanicId;
|
||||||
|
readonly name: string;
|
||||||
|
readonly instruction: string;
|
||||||
|
readonly cooldown: number;
|
||||||
|
readonly passive?: boolean;
|
||||||
|
readonly start: (runtime: MechanicRuntime) => void;
|
||||||
|
readonly advance: (runtime: MechanicRuntime) => void;
|
||||||
|
upcoming?: (motion: BossMotionState, time: number) => UpcomingMechanic;
|
||||||
|
readonly animationCue: (motion: BossMotionState) => BossAnimationCue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishMechanic(runtime: MechanicRuntime, cooldown: number) {
|
||||||
|
runtime.motion.activeMechanicId = null;
|
||||||
|
runtime.motion.mode = "holding";
|
||||||
|
runtime.motion.phaseEndsAt = 0;
|
||||||
|
runtime.motion.nextMechanicAt = runtime.context.time + cooldown;
|
||||||
|
runtime.motion.chargeHitIds = [];
|
||||||
|
runtime.motion.mechanicHitIds = [];
|
||||||
|
runtime.motion.tetherIds = [];
|
||||||
|
runtime.motion.slashLanes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function timedAdvance(runtime: MechanicRuntime, cooldown: number) {
|
||||||
|
if (runtime.context.time >= runtime.motion.phaseEndsAt) finishMechanic(runtime, cooldown);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultUpcoming(definition: Pick<BossMechanicDefinition, "name" | "cooldown">, motion: BossMotionState, time: number): UpcomingMechanic {
|
||||||
|
const remaining = Math.max(0, (motion.activeMechanicId ? motion.phaseEndsAt : motion.nextMechanicAt) - time);
|
||||||
|
return { name: definition.name, remaining, cycle: definition.cooldown, urgent: motion.activeMechanicId !== null || remaining < 2.5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mechanicCopy(id: BossMechanicId) {
|
||||||
|
return MECHANIC_COPY_BY_ID[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bossMechanicIsPassive(id: BossMechanicId) {
|
||||||
|
return BOSS_MECHANIC_REGISTRY[id].passive === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LaneChargeConfig {
|
||||||
|
id: BossMechanicId;
|
||||||
|
warning: number;
|
||||||
|
speed: number;
|
||||||
|
distance: number;
|
||||||
|
width: number;
|
||||||
|
damage: number;
|
||||||
|
knockdown: number;
|
||||||
|
cooldown: number;
|
||||||
|
targetOrder: readonly MemberId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function chargeEndpoint(start: WorldPosition, target: WorldPosition, travelDistance: number): WorldPosition {
|
||||||
|
const angle = angleTo(start, target);
|
||||||
|
return clampToArena([
|
||||||
|
start[0] + Math.sin(angle) * travelDistance,
|
||||||
|
start[1] + Math.cos(angle) * travelDistance,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneChargeDefinition(config: LaneChargeConfig): BossMechanicDefinition {
|
||||||
|
const copy = mechanicCopy(config.id);
|
||||||
|
const definition: BossMechanicDefinition = {
|
||||||
|
id: config.id,
|
||||||
|
name: copy.name,
|
||||||
|
instruction: copy.instruction,
|
||||||
|
cooldown: config.cooldown,
|
||||||
|
start(runtime) {
|
||||||
|
const targetId = chooseLivingTarget(runtime.party, config.targetOrder, runtime.motion.mechanicCount);
|
||||||
|
const end = chargeEndpoint(runtime.motion.position, runtime.context.partyPositions[targetId], config.distance);
|
||||||
|
runtime.motion.mode = "telegraph";
|
||||||
|
runtime.motion.chargeTargetId = targetId;
|
||||||
|
runtime.motion.chargeStart = [...runtime.motion.position];
|
||||||
|
runtime.motion.chargeEnd = end;
|
||||||
|
runtime.motion.chargeHitIds = [];
|
||||||
|
runtime.motion.phaseStartedAt = runtime.context.time;
|
||||||
|
runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
|
||||||
|
runtime.motion.slashLanes = [{
|
||||||
|
id: `${config.id}-${runtime.motion.mechanicCount}`,
|
||||||
|
start: [...runtime.motion.position],
|
||||||
|
end,
|
||||||
|
width: config.width,
|
||||||
|
damage: config.damage,
|
||||||
|
}];
|
||||||
|
runtime.events.push({
|
||||||
|
at: runtime.context.time,
|
||||||
|
message: `${copy.name} targets ${memberName(runtime.party, targetId)}. ${copy.instruction}`,
|
||||||
|
tone: "danger",
|
||||||
|
pulseKind: "charge",
|
||||||
|
targetId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
advance(runtime) {
|
||||||
|
const { context, motion } = runtime;
|
||||||
|
if (motion.mode === "telegraph" && context.time >= motion.phaseEndsAt) {
|
||||||
|
motion.mode = "charging";
|
||||||
|
motion.phaseStartedAt = context.time;
|
||||||
|
motion.phaseEndsAt = context.time + distance(motion.position, motion.chargeEnd) / config.speed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (motion.mode !== "charging") return;
|
||||||
|
const previous = [...motion.position] as WorldPosition;
|
||||||
|
motion.position = moveToward(motion.position, motion.chargeEnd, config.speed * context.delta);
|
||||||
|
runtime.party = runtime.party.map((member) => {
|
||||||
|
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
|
||||||
|
if (pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > config.width * 0.5) return member;
|
||||||
|
motion.chargeHitIds.push(member.id);
|
||||||
|
return {
|
||||||
|
...context.damageMember(member, config.damage, context.partyPositions[member.id], context.time),
|
||||||
|
knockedUntil: context.time + config.knockdown,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
|
||||||
|
motion.position = [...motion.chargeEnd];
|
||||||
|
finishMechanic(runtime, config.cooldown);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animationCue: (motion) => motion.mode === "charging" ? "move" : "attack",
|
||||||
|
};
|
||||||
|
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CircleAttackConfig {
|
||||||
|
id: BossMechanicId;
|
||||||
|
warning: number;
|
||||||
|
radius: number;
|
||||||
|
damage: number;
|
||||||
|
cooldown: number;
|
||||||
|
kind: CircleHazard["kind"];
|
||||||
|
targets: number;
|
||||||
|
duration?: number;
|
||||||
|
tickInterval?: number;
|
||||||
|
centeredOnBoss?: boolean;
|
||||||
|
stagger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function circleAttackDefinition(config: CircleAttackConfig): BossMechanicDefinition {
|
||||||
|
const copy = mechanicCopy(config.id);
|
||||||
|
const definition: BossMechanicDefinition = {
|
||||||
|
id: config.id,
|
||||||
|
name: copy.name,
|
||||||
|
instruction: copy.instruction,
|
||||||
|
cooldown: config.cooldown,
|
||||||
|
start(runtime) {
|
||||||
|
const activatesAt = runtime.context.time + config.warning;
|
||||||
|
const targetIds = Array.from({ length: config.targets }, (_, offset) =>
|
||||||
|
chooseLivingTarget(runtime.party, TARGET_ORDER, runtime.motion.mechanicCount + offset));
|
||||||
|
const centers = config.centeredOnBoss
|
||||||
|
? [[...runtime.motion.position] as WorldPosition]
|
||||||
|
: targetIds.map((targetId) => [...runtime.context.partyPositions[targetId]] as WorldPosition);
|
||||||
|
runtime.motion.mode = config.id === "stormfall" ? "skyfall" : "golem_crownfall";
|
||||||
|
runtime.motion.phaseStartedAt = runtime.context.time;
|
||||||
|
runtime.motion.phaseEndsAt = activatesAt + (centers.length - 1) * (config.stagger ?? 0) + Math.max(0.32, config.duration ?? 0.32);
|
||||||
|
runtime.motion.hazards.push(...centers.map((center, index) => createCircleHazard({
|
||||||
|
id: `${config.id}-${runtime.motion.mechanicCount}-${index}`,
|
||||||
|
kind: config.kind,
|
||||||
|
center,
|
||||||
|
radius: config.radius,
|
||||||
|
activatesAt: activatesAt + index * (config.stagger ?? 0),
|
||||||
|
duration: config.duration ?? 0.32,
|
||||||
|
damage: config.damage,
|
||||||
|
tickInterval: config.tickInterval,
|
||||||
|
})));
|
||||||
|
runtime.events.push({
|
||||||
|
at: runtime.context.time,
|
||||||
|
message: `${copy.name}: ${copy.instruction}`,
|
||||||
|
tone: "danger",
|
||||||
|
pulseKind: "skyfall",
|
||||||
|
targetId: targetIds[0],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
advance(runtime) { timedAdvance(runtime, config.cooldown); },
|
||||||
|
animationCue: () => "special",
|
||||||
|
};
|
||||||
|
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LaneAttackConfig {
|
||||||
|
id: BossMechanicId;
|
||||||
|
warning: number;
|
||||||
|
width: number;
|
||||||
|
damage: number;
|
||||||
|
cooldown: number;
|
||||||
|
angles: readonly number[];
|
||||||
|
rotateFollowup?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneFromAngle(id: string, center: WorldPosition, angle: number, width: number, damage: number): SlashLane {
|
||||||
|
const dx = Math.sin(angle) * 9;
|
||||||
|
const dz = Math.cos(angle) * 9;
|
||||||
|
return { id, start: [center[0] - dx, center[1] - dz], end: [center[0] + dx, center[1] + dz], width, damage };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLanes(runtime: MechanicRuntime, name: string) {
|
||||||
|
const hitIds: MemberId[] = [];
|
||||||
|
runtime.party = runtime.party.map((member) => {
|
||||||
|
if (member.hp <= 0) return member;
|
||||||
|
const lane = runtime.motion.slashLanes.find((entry) =>
|
||||||
|
pointToSegmentDistance(runtime.context.partyPositions[member.id], entry.start, entry.end) <= entry.width * 0.5);
|
||||||
|
if (!lane) return member;
|
||||||
|
hitIds.push(member.id);
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: `${member.name} is struck by ${name}.`, tone: "danger", pulseKind: "slash", targetId: member.id });
|
||||||
|
return runtime.context.damageMember(member, lane.damage, runtime.context.partyPositions[member.id], runtime.context.time);
|
||||||
|
});
|
||||||
|
runtime.motion.mechanicHitIds.push(...hitIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneAttackDefinition(config: LaneAttackConfig): BossMechanicDefinition {
|
||||||
|
const copy = mechanicCopy(config.id);
|
||||||
|
const startLanes = (runtime: MechanicRuntime, rotation = 0) => {
|
||||||
|
const targetId = chooseLivingTarget(runtime.party, TARGET_ORDER, runtime.motion.mechanicCount);
|
||||||
|
const center = runtime.context.partyPositions[targetId];
|
||||||
|
const aimed = angleTo(runtime.motion.position, center) + rotation;
|
||||||
|
runtime.motion.slashLanes = config.angles.map((offset, index) =>
|
||||||
|
laneFromAngle(`${config.id}-${runtime.motion.mechanicCount}-${index}-${rotation}`, center, aimed + offset, config.width, config.damage));
|
||||||
|
runtime.motion.chargeTargetId = targetId;
|
||||||
|
};
|
||||||
|
const definition: BossMechanicDefinition = {
|
||||||
|
id: config.id,
|
||||||
|
name: copy.name,
|
||||||
|
instruction: copy.instruction,
|
||||||
|
cooldown: config.cooldown,
|
||||||
|
start(runtime) {
|
||||||
|
runtime.motion.mode = "mantis_line_telegraph";
|
||||||
|
runtime.motion.phaseStartedAt = runtime.context.time;
|
||||||
|
runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
|
||||||
|
runtime.motion.chargeCount = 0;
|
||||||
|
runtime.motion.mechanicHitIds = [];
|
||||||
|
startLanes(runtime);
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: `${copy.name}: ${copy.instruction}`, tone: "danger", pulseKind: "slash", targetId: runtime.motion.chargeTargetId });
|
||||||
|
},
|
||||||
|
advance(runtime) {
|
||||||
|
if (runtime.context.time < runtime.motion.phaseEndsAt) return;
|
||||||
|
resolveLanes(runtime, copy.name);
|
||||||
|
if (config.rotateFollowup && runtime.motion.chargeCount === 0) {
|
||||||
|
runtime.motion.chargeCount = 1;
|
||||||
|
runtime.motion.mode = "mantis_cross_telegraph";
|
||||||
|
runtime.motion.phaseStartedAt = runtime.context.time;
|
||||||
|
runtime.motion.phaseEndsAt = runtime.context.time + config.warning;
|
||||||
|
startLanes(runtime, config.rotateFollowup);
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: `${copy.name} rotates. Find new safe ground.`, tone: "danger", pulseKind: "slash" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishMechanic(runtime, config.cooldown);
|
||||||
|
},
|
||||||
|
animationCue: () => "attack",
|
||||||
|
};
|
||||||
|
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BULL_TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
|
||||||
|
|
||||||
|
const bullCharge = laneChargeDefinition({ id: "bull-charge", warning: BULL_CHARGE.warning, speed: BULL_CHARGE.speed, distance: BULL_CHARGE.distance, width: BULL_CHARGE.hitRadius * 2, damage: BULL_CHARGE.damage, knockdown: BULL_CHARGE.knockdown, cooldown: BULL_CHARGE.cooldown, targetOrder: BULL_TARGETS });
|
||||||
|
const destructionRush = laneChargeDefinition({ id: "destruction-rush", warning: 1.45, speed: 11.5, distance: 14, width: 2.5, damage: 27, knockdown: 0.55, cooldown: 3.8, targetOrder: BULL_TARGETS });
|
||||||
|
const burrowRush = laneChargeDefinition({ id: "burrow-rush", warning: 1.3, speed: 10.8, distance: 13, width: 2, damage: 25, knockdown: 0.35, cooldown: 3.8, targetOrder: ["aelia", "nia", "orin", "vale", "brann"] });
|
||||||
|
const sidewinderRush = laneChargeDefinition({ id: "sidewinder-rush", warning: 1.25, speed: 11, distance: 13.2, width: 2.3, damage: 24, knockdown: 0.42, cooldown: 3.7, targetOrder: BULL_TARGETS });
|
||||||
|
|
||||||
|
const crushingPounce: BossMechanicDefinition = {
|
||||||
|
id: "crushing-pounce",
|
||||||
|
name: bossMechanicName("crushing-pounce"),
|
||||||
|
instruction: mechanicCopy("crushing-pounce").instruction,
|
||||||
|
cooldown: 4,
|
||||||
|
start(runtime) {
|
||||||
|
const targetId = chooseLivingTarget(runtime.party, ["aelia", "nia", "orin", "vale", "brann"], runtime.motion.mechanicCount);
|
||||||
|
runtime.motion.mode = "stacking";
|
||||||
|
runtime.motion.pounceTargetId = targetId;
|
||||||
|
runtime.motion.pounceCenter = [...runtime.context.partyPositions[targetId]];
|
||||||
|
runtime.motion.phaseStartedAt = runtime.context.time;
|
||||||
|
runtime.motion.phaseEndsAt = runtime.context.time + BULL_POUNCE.stackDuration;
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: `Crushing Pounce marks ${memberName(runtime.party, targetId)}. Stack to split the impact.`, tone: "danger", pulseKind: "pounce", targetId });
|
||||||
|
},
|
||||||
|
advance(runtime) {
|
||||||
|
const { motion, context } = runtime;
|
||||||
|
if (motion.mode === "stacking") {
|
||||||
|
motion.pounceCenter = [...context.partyPositions[motion.pounceTargetId]];
|
||||||
|
if (context.time < motion.phaseEndsAt) return;
|
||||||
|
motion.mode = "pouncing";
|
||||||
|
motion.chargeStart = [...motion.position];
|
||||||
|
motion.chargeEnd = [...motion.pounceCenter];
|
||||||
|
motion.phaseStartedAt = context.time;
|
||||||
|
motion.phaseEndsAt = context.time + BULL_POUNCE.leapDuration;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (motion.mode !== "pouncing") return;
|
||||||
|
motion.position = moveToward(motion.position, motion.chargeEnd, 16 * context.delta);
|
||||||
|
if (context.time < motion.phaseEndsAt && distance(motion.position, motion.chargeEnd) >= 0.08) return;
|
||||||
|
const stackedIds = runtime.party.filter((member) => member.hp > 0 && distance(context.partyPositions[member.id], motion.pounceCenter) <= BULL_POUNCE.stackRadius).map((member) => member.id);
|
||||||
|
const damage = BULL_POUNCE.sharedDamage / Math.max(1, stackedIds.length);
|
||||||
|
runtime.party = runtime.party.map((member) => stackedIds.includes(member.id)
|
||||||
|
? context.damageMember(member, damage, context.partyPositions[member.id], context.time)
|
||||||
|
: member);
|
||||||
|
runtime.events.push({ at: context.time, message: `Crushing Pounce deals ${Math.round(damage)} damage across ${stackedIds.length} stacked allies.`, tone: "danger", pulseKind: "pounce", targetId: motion.pounceTargetId });
|
||||||
|
finishMechanic(runtime, BULL_POUNCE.cooldown);
|
||||||
|
},
|
||||||
|
animationCue: (motion) => motion.mode === "pouncing" ? "special" : "attack",
|
||||||
|
};
|
||||||
|
crushingPounce.upcoming = (motion, time) => defaultUpcoming(crushingPounce, motion, time);
|
||||||
|
|
||||||
|
function instantTimedDefinition(id: BossMechanicId, cooldown: number, start: (runtime: MechanicRuntime) => void, cue: BossAnimationCue = "attack"): BossMechanicDefinition {
|
||||||
|
const copy = mechanicCopy(id);
|
||||||
|
const definition: BossMechanicDefinition = {
|
||||||
|
id, name: copy.name, instruction: copy.instruction, cooldown,
|
||||||
|
start(runtime) {
|
||||||
|
runtime.motion.mode = "golem_shockwave";
|
||||||
|
runtime.motion.phaseStartedAt = runtime.context.time;
|
||||||
|
runtime.motion.phaseEndsAt = runtime.context.time + 0.55;
|
||||||
|
start(runtime);
|
||||||
|
},
|
||||||
|
advance(runtime) { timedAdvance(runtime, cooldown); },
|
||||||
|
animationCue: () => cue,
|
||||||
|
};
|
||||||
|
definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time);
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cinderNova = instantTimedDefinition("cinder-nova", 5, (runtime) => {
|
||||||
|
runtime.party = runtime.party.map((member) => member.hp > 0
|
||||||
|
? runtime.context.damageMember(member, 13, runtime.context.partyPositions[member.id], runtime.context.time)
|
||||||
|
: member);
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: "Cinder Nova strikes the party.", tone: "danger", pulseKind: "boss" });
|
||||||
|
}, "special");
|
||||||
|
|
||||||
|
const emberBrand = instantTimedDefinition("ember-brand", 5, (runtime) => {
|
||||||
|
const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount);
|
||||||
|
runtime.party = runtime.party.map((member) => member.id === targetId ? {
|
||||||
|
...member,
|
||||||
|
debuffs: [...member.debuffs, { id: `ember-brand-${runtime.motion.mechanicCount}`, name: "Ember Brand", expiresAt: runtime.context.time + 7, nextTickAt: runtime.context.time + 1, tickDamage: 6 }],
|
||||||
|
} : member);
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: `Ember Brand afflicts ${memberName(runtime.party, targetId)}.`, tone: "danger", pulseKind: "debuff", targetId });
|
||||||
|
});
|
||||||
|
|
||||||
|
const bindingWeb: BossMechanicDefinition = {
|
||||||
|
id: "binding-web", name: bossMechanicName("binding-web"), instruction: mechanicCopy("binding-web").instruction, cooldown: 4,
|
||||||
|
start(runtime) {
|
||||||
|
const pairs: readonly (readonly [MemberId, MemberId])[] = [["brann", "vale"], ["nia", "orin"], ["aelia", "nia"]];
|
||||||
|
const pair = pairs[(runtime.motion.mechanicCount - 1) % pairs.length];
|
||||||
|
const first = chooseLivingTarget(runtime.party, pair, 0);
|
||||||
|
const second = chooseLivingTarget(runtime.party, pair.filter((id) => id !== first), 0);
|
||||||
|
runtime.motion.mode = "tethering";
|
||||||
|
runtime.motion.tetherIds = [first, second];
|
||||||
|
runtime.motion.tetherBreakDistance = 6.8;
|
||||||
|
runtime.motion.phaseStartedAt = runtime.context.time;
|
||||||
|
runtime.motion.phaseEndsAt = runtime.context.time + 4.5;
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: `Binding Web links ${memberName(runtime.party, first)} and ${memberName(runtime.party, second)}. Spread apart.`, tone: "danger", pulseKind: "tether", targetId: first });
|
||||||
|
},
|
||||||
|
advance(runtime) {
|
||||||
|
const [first, second] = runtime.motion.tetherIds;
|
||||||
|
if (!first || !second || distance(runtime.context.partyPositions[first], runtime.context.partyPositions[second]) >= runtime.motion.tetherBreakDistance) {
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
|
||||||
|
finishMechanic(runtime, 4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (runtime.context.time < runtime.motion.phaseEndsAt) return;
|
||||||
|
runtime.party = runtime.party.map((member) => runtime.motion.tetherIds.includes(member.id)
|
||||||
|
? { ...runtime.context.damageMember(member, 24, runtime.context.partyPositions[member.id], runtime.context.time), knockedUntil: runtime.context.time + 1.4 }
|
||||||
|
: member);
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
|
||||||
|
finishMechanic(runtime, 4);
|
||||||
|
},
|
||||||
|
animationCue: () => "attack",
|
||||||
|
};
|
||||||
|
bindingWeb.upcoming = (motion, time) => defaultUpcoming(bindingWeb, motion, time);
|
||||||
|
|
||||||
|
const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => {
|
||||||
|
const targets = [0, 1].map((offset) => chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + offset));
|
||||||
|
runtime.motion.mode = "venom_cast";
|
||||||
|
runtime.motion.phaseEndsAt = runtime.context.time + VENOM_PURGE.castDuration;
|
||||||
|
runtime.party = runtime.party.map((member) => targets.includes(member.id) ? {
|
||||||
|
...member,
|
||||||
|
debuffs: [...member.debuffs, { id: `widow-venom-${runtime.motion.mechanicCount}-${member.id}`, name: "Widow Venom", expiresAt: runtime.context.time + VENOM_PURGE.duration, nextTickAt: runtime.context.time + 1, tickDamage: VENOM_PURGE.tickDamage }],
|
||||||
|
} : member);
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: "Venom Purge applies Widow Venom. Move away before cleansing.", tone: "danger", pulseKind: "venom", targetId: targets[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const stormBreath: BossMechanicDefinition = {
|
||||||
|
id: "storm-breath", name: bossMechanicName("storm-breath"), instruction: mechanicCopy("storm-breath").instruction, cooldown: 4,
|
||||||
|
start(runtime) {
|
||||||
|
const aimed = angleTo(runtime.motion.position, runtime.context.partyPositions.brann);
|
||||||
|
const direction = runtime.motion.mechanicCount % 2 === 0 ? 1 : -1;
|
||||||
|
runtime.motion.mode = "breath_telegraph";
|
||||||
|
runtime.motion.breathStartAngle = aimed - direction * Math.PI * 0.475;
|
||||||
|
runtime.motion.breathEndAngle = aimed + direction * Math.PI * 0.475;
|
||||||
|
runtime.motion.breathAngle = runtime.motion.breathStartAngle;
|
||||||
|
runtime.motion.phaseStartedAt = runtime.context.time;
|
||||||
|
runtime.motion.phaseEndsAt = runtime.context.time + SKY_SWEEPER_BREATH.telegraphDuration;
|
||||||
|
runtime.motion.mechanicNextDamageAt = {};
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: "Storm Breath gathers. Rotate behind the sweep.", tone: "danger", pulseKind: "breath" });
|
||||||
|
},
|
||||||
|
advance(runtime) {
|
||||||
|
const { motion, context } = runtime;
|
||||||
|
if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
|
||||||
|
motion.mode = "breath_sweeping";
|
||||||
|
motion.phaseStartedAt = context.time;
|
||||||
|
motion.phaseEndsAt = context.time + SKY_SWEEPER_BREATH.sweepDuration;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (motion.mode !== "breath_sweeping") return;
|
||||||
|
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / SKY_SWEEPER_BREATH.sweepDuration));
|
||||||
|
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
|
||||||
|
runtime.party = runtime.party.map((member) => {
|
||||||
|
if (member.hp <= 0) return member;
|
||||||
|
const dx = context.partyPositions[member.id][0] - motion.position[0];
|
||||||
|
const dz = context.partyPositions[member.id][1] - motion.position[1];
|
||||||
|
const memberAngle = Math.atan2(dx, dz);
|
||||||
|
const exposed = Math.hypot(dx, dz) <= SKY_SWEEPER_BREATH.range && Math.abs(Math.atan2(Math.sin(memberAngle - motion.breathAngle), Math.cos(memberAngle - motion.breathAngle))) <= SKY_SWEEPER_BREATH.halfAngle;
|
||||||
|
if (!exposed) return member;
|
||||||
|
const nextAt = motion.mechanicNextDamageAt[member.id] ?? context.time;
|
||||||
|
if (nextAt > context.time) return member;
|
||||||
|
motion.mechanicNextDamageAt[member.id] = context.time + SKY_SWEEPER_BREATH.tickInterval;
|
||||||
|
return context.damageMember(member, SKY_SWEEPER_BREATH.tickDamage, context.partyPositions[member.id], context.time);
|
||||||
|
});
|
||||||
|
if (context.time >= motion.phaseEndsAt) finishMechanic(runtime, SKY_SWEEPER_BREATH.cooldown);
|
||||||
|
},
|
||||||
|
animationCue: () => "attack",
|
||||||
|
};
|
||||||
|
stormBreath.upcoming = (motion, time) => defaultUpcoming(stormBreath, motion, time);
|
||||||
|
|
||||||
|
const stormfall = circleAttackDefinition({ id: "stormfall", warning: 2, radius: 1.8, damage: 30, cooldown: 4, kind: "skyfall", targets: 3, duration: 5, tickInterval: 1, stagger: 0.9 });
|
||||||
|
const crushingTide = circleAttackDefinition({ id: "crushing-tide", warning: 1.35, radius: 1.7, damage: 26, cooldown: 3.7, kind: "tidal_burst", targets: 3 });
|
||||||
|
const hauntingRifts = circleAttackDefinition({ id: "haunting-rifts", warning: 1.45, radius: 1.75, damage: 5, cooldown: 3.9, kind: "soul_rift", targets: 2, duration: 4.2, tickInterval: 0.8 });
|
||||||
|
const ultimateSkyfall = circleAttackDefinition({ id: "ultimate-skyfall", warning: 1.5, radius: 1.85, damage: 28, cooldown: 4, kind: "crownfall", targets: 3 });
|
||||||
|
const ruinQuake = circleAttackDefinition({ id: "ruin-quake", warning: 1.35, radius: 3.6, damage: 31, cooldown: 3.8, kind: "quake", targets: 1, centeredOnBoss: true });
|
||||||
|
|
||||||
|
const elementalBeam = laneAttackDefinition({ id: "elemental-beam", warning: 0.9, width: 1.65, damage: 32, cooldown: 3.4, angles: [0] });
|
||||||
|
const guardianCross = laneAttackDefinition({ id: "guardian-cross", warning: 0.9, width: 1.45, damage: 25, cooldown: 3.4, angles: [-Math.PI * 0.18, Math.PI * 0.18] });
|
||||||
|
const destructionPulse = laneAttackDefinition({ id: "destruction-pulse", warning: 1.2, width: 1.25, damage: 24, cooldown: 3.8, angles: [0, Math.PI / 3, -Math.PI / 3] });
|
||||||
|
const vineScissors = laneAttackDefinition({ id: "vine-scissors", warning: 1.25, width: 1.55, damage: 22, cooldown: 3.9, angles: [0, Math.PI / 2], rotateFollowup: Math.PI / 4 });
|
||||||
|
|
||||||
|
const ricochetRush: BossMechanicDefinition = {
|
||||||
|
...laneChargeDefinition({ id: "ricochet-rush", warning: 1.25, speed: 12.5, distance: 13, width: 2.25, damage: 22, knockdown: 0.4, cooldown: 3.6, targetOrder: ["orin", "nia", "aelia", "vale", "brann"] }),
|
||||||
|
advance(runtime) {
|
||||||
|
const motion = runtime.motion;
|
||||||
|
if (motion.mode === "telegraph" && runtime.context.time >= motion.phaseEndsAt) {
|
||||||
|
motion.mode = "charging";
|
||||||
|
motion.chargeCount = 0;
|
||||||
|
motion.phaseStartedAt = runtime.context.time;
|
||||||
|
motion.phaseEndsAt = runtime.context.time + distance(motion.position, motion.chargeEnd) / 12.5;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (motion.mode !== "charging") return;
|
||||||
|
const previous = [...motion.position] as WorldPosition;
|
||||||
|
motion.position = moveToward(motion.position, motion.chargeEnd, 12.5 * runtime.context.delta);
|
||||||
|
runtime.party = runtime.party.map((member) => {
|
||||||
|
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(runtime.context.partyPositions[member.id], previous, motion.position) > 1.125) return member;
|
||||||
|
motion.chargeHitIds.push(member.id);
|
||||||
|
return runtime.context.damageMember(member, 22, runtime.context.partyPositions[member.id], runtime.context.time);
|
||||||
|
});
|
||||||
|
if (distance(motion.position, motion.chargeEnd) >= 0.08 && runtime.context.time < motion.phaseEndsAt) return;
|
||||||
|
motion.hazards.push(createCircleHazard({ id: `ricochet-lava-${motion.mechanicCount}-${motion.chargeCount}`, kind: "lava_pool", center: motion.chargeEnd, radius: 1.5, activatesAt: runtime.context.time, duration: 4.5, damage: 5, tickInterval: 0.8 }));
|
||||||
|
if (motion.chargeCount === 0) {
|
||||||
|
const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, motion.mechanicCount + 2);
|
||||||
|
const start = [...motion.chargeEnd] as WorldPosition;
|
||||||
|
const end = chargeEndpoint(start, runtime.context.partyPositions[targetId], 13);
|
||||||
|
motion.position = start;
|
||||||
|
motion.chargeStart = start;
|
||||||
|
motion.chargeEnd = end;
|
||||||
|
motion.chargeTargetId = targetId;
|
||||||
|
motion.chargeHitIds = [];
|
||||||
|
motion.chargeCount = 1;
|
||||||
|
motion.phaseEndsAt = runtime.context.time + distance(start, end) / 12.5;
|
||||||
|
motion.slashLanes = [{ id: `ricochet-${motion.mechanicCount}-1`, start, end, width: 2.25, damage: 22 }];
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: `Ricochet Rush rebounds toward ${memberName(runtime.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishMechanic(runtime, 3.6);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const meteorSlam: BossMechanicDefinition = {
|
||||||
|
id: "meteor-slam", name: bossMechanicName("meteor-slam"), instruction: mechanicCopy("meteor-slam").instruction, cooldown: 3.6,
|
||||||
|
start(runtime) {
|
||||||
|
const activatesAt = runtime.context.time + 1.3;
|
||||||
|
runtime.motion.mode = "cinderback_slam";
|
||||||
|
runtime.motion.phaseStartedAt = runtime.context.time;
|
||||||
|
runtime.motion.phaseEndsAt = activatesAt + 0.3;
|
||||||
|
runtime.motion.hazards.push(createCircleHazard({ id: `meteor-slam-${runtime.motion.mechanicCount}`, kind: "quake", center: runtime.motion.position, radius: 3.1, activatesAt, duration: 0.3, damage: 29 }));
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
const angle = index / 3 * Math.PI * 2;
|
||||||
|
runtime.motion.hazards.push(createCircleHazard({ id: `meteor-flame-${runtime.motion.mechanicCount}-${index}`, kind: "lava_pool", center: clampToArena([runtime.motion.position[0] + Math.sin(angle) * 3.7, runtime.motion.position[1] + Math.cos(angle) * 3.7]), radius: 1.5, activatesAt, duration: 4.5, damage: 5, tickInterval: 0.8 }));
|
||||||
|
}
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: "Meteor Slam: leave the impact and spreading flame.", tone: "danger", pulseKind: "boss" });
|
||||||
|
},
|
||||||
|
advance(runtime) { timedAdvance(runtime, 3.6); },
|
||||||
|
animationCue: () => "special",
|
||||||
|
};
|
||||||
|
meteorSlam.upcoming = (motion, time) => defaultUpcoming(meteorSlam, motion, time);
|
||||||
|
|
||||||
|
const hourglassEruption: BossMechanicDefinition = {
|
||||||
|
id: "hourglass-eruption", name: bossMechanicName("hourglass-eruption"), instruction: mechanicCopy("hourglass-eruption").instruction, cooldown: 3.8,
|
||||||
|
start(runtime) {
|
||||||
|
const activatesAt = runtime.context.time + 1.55;
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + index);
|
||||||
|
runtime.motion.hazards.push(createCircleHazard({ id: `stinger-${runtime.motion.mechanicCount}-${index}`, kind: "stinger_eruption", center: runtime.context.partyPositions[targetId], radius: 1.75, activatesAt, duration: 0.32, damage: 27 }));
|
||||||
|
}
|
||||||
|
runtime.motion.hazards.push(createCircleHazard({ id: `hourglass-${runtime.motion.mechanicCount}`, kind: "hourglass", center: clampToArena([runtime.motion.position[0], runtime.motion.position[1] + 3]), radius: 2.55, activatesAt: activatesAt + 0.9, duration: 3.6, damage: 6, tickInterval: 0.75 }));
|
||||||
|
runtime.motion.mode = "sandglass_hourglass";
|
||||||
|
runtime.motion.phaseStartedAt = runtime.context.time;
|
||||||
|
runtime.motion.phaseEndsAt = activatesAt + 4.5;
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: "Hourglass Eruption: leave the eruptions and moving zone.", tone: "danger", pulseKind: "skyfall" });
|
||||||
|
},
|
||||||
|
advance(runtime) { timedAdvance(runtime, 3.8); },
|
||||||
|
animationCue: () => "special",
|
||||||
|
};
|
||||||
|
hourglassEruption.upcoming = (motion, time) => defaultUpcoming(hourglassEruption, motion, time);
|
||||||
|
|
||||||
|
const triBurst: BossMechanicDefinition = {
|
||||||
|
id: "tri-burst", name: bossMechanicName("tri-burst"), instruction: mechanicCopy("tri-burst").instruction, cooldown: 4,
|
||||||
|
start(runtime) {
|
||||||
|
const firstActivation = runtime.context.time + 1.2;
|
||||||
|
const bands = [{ innerRadius: 0, radius: 2.35 }, { innerRadius: 2.35, radius: 4.7 }, { innerRadius: 4.7, radius: 7.05 }];
|
||||||
|
runtime.motion.mode = "golem_shockwave";
|
||||||
|
runtime.motion.phaseStartedAt = runtime.context.time;
|
||||||
|
runtime.motion.phaseEndsAt = firstActivation + 1.62;
|
||||||
|
runtime.motion.hazards.push(...bands.map((band, index) => createCircleHazard({ id: `tri-burst-${runtime.motion.mechanicCount}-${index}`, kind: "royal_shockwave", center: runtime.motion.position, innerRadius: band.innerRadius, radius: band.radius, activatesAt: firstActivation + index * 0.65, duration: 0.3, damage: 19 })));
|
||||||
|
runtime.events.push({ at: runtime.context.time, message: "Tri-Burst expands in three rings. Follow the safe bands.", tone: "danger", pulseKind: "boss" });
|
||||||
|
},
|
||||||
|
advance(runtime) { timedAdvance(runtime, 4); },
|
||||||
|
animationCue: () => "special",
|
||||||
|
};
|
||||||
|
triBurst.upcoming = (motion, time) => defaultUpcoming(triBurst, motion, time);
|
||||||
|
|
||||||
|
function telegraphDefinition(id: BossMechanicId): BossMechanicDefinition {
|
||||||
|
const copy = mechanicCopy(id);
|
||||||
|
const definition: BossMechanicDefinition = {
|
||||||
|
id, name: copy.name, instruction: copy.instruction, cooldown: 5,
|
||||||
|
start(runtime) {
|
||||||
|
const started = beginPoolMechanic(runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, id);
|
||||||
|
runtime.motion = started.motion;
|
||||||
|
runtime.motion.mode = "golem_crownfall";
|
||||||
|
runtime.events.push(started.event);
|
||||||
|
},
|
||||||
|
advance(runtime) {
|
||||||
|
for (const telegraph of runtime.motion.poolTelegraphs) {
|
||||||
|
if (telegraph.kind === "memory") {
|
||||||
|
if (!telegraph.resolved) runtime.party = resolveMemorySequence(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (telegraph.kind === "soul-siphon") {
|
||||||
|
if (!telegraph.resolved) runtime.party = resolveSoulSiphon(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (telegraph.resolved || runtime.context.time < telegraph.activatesAt) continue;
|
||||||
|
runtime.party = resolveTelegraph(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events);
|
||||||
|
telegraph.resolved = true;
|
||||||
|
}
|
||||||
|
runtime.motion.poolTelegraphs = runtime.motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > runtime.context.time);
|
||||||
|
if (!runtime.motion.poolTelegraphs.length) finishMechanic(runtime, 5);
|
||||||
|
},
|
||||||
|
upcoming(motion, time) { return upcomingPooledMechanic(motion, time) ?? defaultUpcoming(definition, motion, time); },
|
||||||
|
animationCue: () => id === "soul-siphon" || id === "memory-sequence" ? "special" : "attack",
|
||||||
|
};
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basicMelee: BossMechanicDefinition = {
|
||||||
|
id: "basic-melee",
|
||||||
|
name: bossMechanicName("basic-melee"),
|
||||||
|
instruction: mechanicCopy("basic-melee").instruction,
|
||||||
|
cooldown: 0,
|
||||||
|
passive: true,
|
||||||
|
start() {},
|
||||||
|
advance(runtime) {
|
||||||
|
applyMelee(runtime.boss, runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, 2.5, 15, runtime.context.damageMember);
|
||||||
|
},
|
||||||
|
animationCue: () => "idle",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BOSS_MECHANIC_REGISTRY: Record<BossMechanicId, BossMechanicDefinition> = {
|
||||||
|
"basic-melee": basicMelee,
|
||||||
|
"bull-charge": bullCharge,
|
||||||
|
"crushing-pounce": crushingPounce,
|
||||||
|
"cinder-nova": cinderNova,
|
||||||
|
"ember-brand": emberBrand,
|
||||||
|
"binding-web": bindingWeb,
|
||||||
|
"venom-purge": venomPurge,
|
||||||
|
"storm-breath": stormBreath,
|
||||||
|
stormfall,
|
||||||
|
"elemental-beam": elementalBeam,
|
||||||
|
"guardian-cross": guardianCross,
|
||||||
|
"destruction-rush": destructionRush,
|
||||||
|
"ruin-quake": ruinQuake,
|
||||||
|
"destruction-pulse": destructionPulse,
|
||||||
|
"ricochet-rush": ricochetRush,
|
||||||
|
"meteor-slam": meteorSlam,
|
||||||
|
"burrow-rush": burrowRush,
|
||||||
|
"hourglass-eruption": hourglassEruption,
|
||||||
|
"sidewinder-rush": sidewinderRush,
|
||||||
|
"crushing-tide": crushingTide,
|
||||||
|
"vine-scissors": vineScissors,
|
||||||
|
"haunting-rifts": hauntingRifts,
|
||||||
|
"tri-burst": triBurst,
|
||||||
|
"ultimate-skyfall": ultimateSkyfall,
|
||||||
|
"meteor-spread": telegraphDefinition("meteor-spread"),
|
||||||
|
"hollow-collapse": telegraphDefinition("hollow-collapse"),
|
||||||
|
"aetheric-soak": telegraphDefinition("aetheric-soak"),
|
||||||
|
"prism-beam": telegraphDefinition("prism-beam"),
|
||||||
|
"memory-sequence": telegraphDefinition("memory-sequence"),
|
||||||
|
"soul-siphon": telegraphDefinition("soul-siphon"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function scheduledMechanicId(
|
||||||
|
loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
|
||||||
|
mechanicCount: number,
|
||||||
|
) {
|
||||||
|
let activeCount = 0;
|
||||||
|
for (const id of loadout) if (!BOSS_MECHANIC_REGISTRY[id].passive) activeCount += 1;
|
||||||
|
if (!activeCount) throw new Error("Boss loadout requires at least one active mechanic.");
|
||||||
|
let targetIndex = mechanicCount % activeCount;
|
||||||
|
for (const id of loadout) {
|
||||||
|
if (BOSS_MECHANIC_REGISTRY[id].passive) continue;
|
||||||
|
if (targetIndex === 0) return id;
|
||||||
|
targetIndex -= 1;
|
||||||
|
}
|
||||||
|
return loadout[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advanceMechanicLoadout(
|
||||||
|
context: BossMechanicContext,
|
||||||
|
loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
|
||||||
|
): BossMechanicResult {
|
||||||
|
const runtime: MechanicRuntime = {
|
||||||
|
context,
|
||||||
|
boss: { ...context.boss },
|
||||||
|
motion: cloneMotion(context.motion),
|
||||||
|
party: context.party,
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (runtime.motion.mode === "holding") returnBossToArenaCenter(runtime.motion, context.delta, 2);
|
||||||
|
|
||||||
|
for (const mechanicId of loadout) {
|
||||||
|
const definition = BOSS_MECHANIC_REGISTRY[mechanicId];
|
||||||
|
if (definition.passive) definition.advance(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime.motion.activeMechanicId) {
|
||||||
|
BOSS_MECHANIC_REGISTRY[runtime.motion.activeMechanicId].advance(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!runtime.motion.activeMechanicId && context.time >= runtime.motion.nextMechanicAt) {
|
||||||
|
const mechanicId = scheduledMechanicId(loadout, runtime.motion.mechanicCount);
|
||||||
|
runtime.motion.mechanicCount += 1;
|
||||||
|
runtime.motion.activeMechanicId = mechanicId;
|
||||||
|
runtime.motion.nextMechanicAt = Number.POSITIVE_INFINITY;
|
||||||
|
BOSS_MECHANIC_REGISTRY[mechanicId].start(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.party = resolveCircleHazards(runtime.motion, runtime.party, context.partyPositions, context.time, context.damageMember, runtime.events);
|
||||||
|
return { boss: runtime.boss, motion: runtime.motion, party: runtime.party, events: runtime.events };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upcomingLoadoutMechanic(
|
||||||
|
loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]],
|
||||||
|
motion: BossMotionState,
|
||||||
|
time: number,
|
||||||
|
): UpcomingMechanic {
|
||||||
|
const id = motion.activeMechanicId ?? scheduledMechanicId(loadout, motion.mechanicCount);
|
||||||
|
const definition = BOSS_MECHANIC_REGISTRY[id];
|
||||||
|
return definition.upcoming?.(motion, time) ?? defaultUpcoming(definition, motion, time);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bossAnimationCue(motion: BossMotionState): BossAnimationCue {
|
||||||
|
return motion.activeMechanicId ? BOSS_MECHANIC_REGISTRY[motion.activeMechanicId].animationCue(motion) : "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dropVenomPool(motion: BossMotionState, memberId: MemberId, center: WorldPosition, time: number) {
|
||||||
|
const next = cloneMotion(motion);
|
||||||
|
next.hazards.push(createCircleHazard({ id: `venom-pool-${memberId}-${time.toFixed(2)}`, kind: "venom_pool", center, radius: VENOM_PURGE.poolRadius, activatesAt: time + 0.25, duration: VENOM_PURGE.poolDuration, damage: VENOM_PURGE.poolDamage, tickInterval: 1 }));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleMechanicDispel(
|
||||||
|
motion: BossMotionState,
|
||||||
|
memberId: MemberId,
|
||||||
|
position: WorldPosition,
|
||||||
|
time: number,
|
||||||
|
debuffNames: readonly string[],
|
||||||
|
) {
|
||||||
|
if (debuffNames.includes("Widow Venom")) {
|
||||||
|
return {
|
||||||
|
motion: dropVenomPool(motion, memberId, position, time),
|
||||||
|
message: "Widow Venom purged. A venom pool forms where the target stood.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { motion, message: "Harmful magic removed." };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
|
||||||
import { angleTo, pointToSegmentDistance } from "../geometry";
|
|
||||||
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
|
||||||
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
|
||||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
|
||||||
|
|
||||||
export const MOURNVEIL = {
|
|
||||||
firstAt: 5.3,
|
|
||||||
repeatDelay: 3.9,
|
|
||||||
crossWarning: 1.35,
|
|
||||||
followupWarning: 1.05,
|
|
||||||
laneWidth: 1.55,
|
|
||||||
laneDamage: 22,
|
|
||||||
riftWarning: 1.45,
|
|
||||||
riftRadius: 1.75,
|
|
||||||
riftDamage: 5,
|
|
||||||
riftDuration: 4.2,
|
|
||||||
recoverDuration: 0.75,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const RIFT_TARGETS: readonly (readonly MemberId[])[] = [
|
|
||||||
["aelia", "nia"],
|
|
||||||
["orin", "vale"],
|
|
||||||
["brann", "aelia"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createMournveilState(): BossState {
|
|
||||||
const definition = BOSS_DEFINITIONS["mournveil-ghost"];
|
|
||||||
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMournveilMotion(): BossMotionState {
|
|
||||||
return { ...createBaseMotion("mournveil-ghost"), position: [0, -6.6], nextMechanicAt: MOURNVEIL.firstAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
function crossLanes(center: WorldPosition, angle: number, mechanicCount: number, phase: number): SlashLane[] {
|
|
||||||
return [angle, angle + Math.PI / 2].map((laneAngle, index) => {
|
|
||||||
const dx = Math.sin(laneAngle) * 9;
|
|
||||||
const dz = Math.cos(laneAngle) * 9;
|
|
||||||
return {
|
|
||||||
id: `mournveil-cross-${mechanicCount}-${phase}-${index}`,
|
|
||||||
start: [center[0] - dx, center[1] - dz],
|
|
||||||
end: [center[0] + dx, center[1] + dz],
|
|
||||||
width: MOURNVEIL.laneWidth,
|
|
||||||
damage: MOURNVEIL.laneDamage,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCross(motion: BossMotionState, context: BossMechanicContext, party: BossMechanicContext["party"], events: BossMechanicResult["events"]) {
|
|
||||||
const hitIds: MemberId[] = [];
|
|
||||||
const nextParty = party.map((member) => {
|
|
||||||
if (member.hp <= 0) return member;
|
|
||||||
const hit = motion.slashLanes.some((lane) => pointToSegmentDistance(context.partyPositions[member.id], lane.start, lane.end) <= lane.width * 0.5);
|
|
||||||
if (!hit) return member;
|
|
||||||
hitIds.push(member.id);
|
|
||||||
events.push({ at: context.time, message: `${member.name} is cut by Vine Scissors.`, tone: "danger", pulseKind: "slash", targetId: member.id });
|
|
||||||
return context.damageMember(member, MOURNVEIL.laneDamage, context.partyPositions[member.id], context.time);
|
|
||||||
});
|
|
||||||
return { party: nextParty, hitIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
|
|
||||||
const mechanicCount = motion.mechanicCount + 1;
|
|
||||||
if (motion.mechanicCount % 2 === 0) {
|
|
||||||
const targetId = (["aelia", "nia", "orin", "vale", "brann"] as const)[motion.mechanicCount % 5];
|
|
||||||
const angle = angleTo(motion.position, context.partyPositions[targetId]);
|
|
||||||
events.push({ at: context.time, message: "Vine Scissors carve a spectral cross. A second cut will rotate.", tone: "danger", pulseKind: "slash", targetId });
|
|
||||||
return {
|
|
||||||
...motion,
|
|
||||||
mode: "ghost_soul_cross" as const,
|
|
||||||
breathStartAngle: angle,
|
|
||||||
chargeCount: 0,
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: context.time + MOURNVEIL.crossWarning,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
mechanicCount,
|
|
||||||
mechanicHitIds: [],
|
|
||||||
slashLanes: crossLanes(motion.position, angle, mechanicCount, 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const activatesAt = context.time + MOURNVEIL.riftWarning;
|
|
||||||
const targetSet = RIFT_TARGETS[Math.floor(motion.mechanicCount / 2) % RIFT_TARGETS.length];
|
|
||||||
events.push({ at: context.time, message: "Haunting Rifts follow two allies. Carry them away from formation.", tone: "danger", pulseKind: "skyfall", targetId: targetSet[0] });
|
|
||||||
return {
|
|
||||||
...motion,
|
|
||||||
mode: "ghost_haunting" as const,
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: activatesAt + 0.35,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
mechanicCount,
|
|
||||||
hazards: [
|
|
||||||
...motion.hazards,
|
|
||||||
...targetSet.map((targetId, index) => createCircleHazard({
|
|
||||||
id: `mournveil-rift-${mechanicCount}-${index}`,
|
|
||||||
kind: "soul_rift",
|
|
||||||
center: context.partyPositions[targetId],
|
|
||||||
radius: MOURNVEIL.riftRadius,
|
|
||||||
activatesAt,
|
|
||||||
duration: MOURNVEIL.riftDuration,
|
|
||||||
damage: MOURNVEIL.riftDamage,
|
|
||||||
tickInterval: 0.8,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advanceMournveilMechanics(context: BossMechanicContext): BossMechanicResult {
|
|
||||||
const boss = { ...context.boss };
|
|
||||||
let motion = cloneMotion(context.motion);
|
|
||||||
const events: BossMechanicResult["events"] = [];
|
|
||||||
let party = context.party;
|
|
||||||
|
|
||||||
if (motion.mode === "holding") {
|
|
||||||
returnBossToArenaCenter(motion, context.delta, 1.7);
|
|
||||||
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
|
|
||||||
} else if (motion.mode === "ghost_soul_cross" && context.time >= motion.phaseEndsAt) {
|
|
||||||
const resolved = resolveCross(motion, { ...context, party }, party, events);
|
|
||||||
party = resolved.party;
|
|
||||||
const followupAngle = motion.breathStartAngle + Math.PI / 4;
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "ghost_soul_cross_followup",
|
|
||||||
chargeCount: 1,
|
|
||||||
mechanicHitIds: resolved.hitIds,
|
|
||||||
slashLanes: crossLanes(motion.position, followupAngle, motion.mechanicCount, 1),
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: context.time + MOURNVEIL.followupWarning,
|
|
||||||
};
|
|
||||||
events.push({ at: context.time, message: "Vine Scissors rotate. Find the new safe quadrant.", tone: "danger", pulseKind: "slash" });
|
|
||||||
} else if (motion.mode === "ghost_soul_cross_followup" && context.time >= motion.phaseEndsAt) {
|
|
||||||
const resolved = resolveCross(motion, { ...context, party }, party, events);
|
|
||||||
party = resolved.party;
|
|
||||||
motion = { ...motion, mode: "ghost_recover", mechanicHitIds: [...new Set([...motion.mechanicHitIds, ...resolved.hitIds])], phaseEndsAt: context.time + MOURNVEIL.recoverDuration };
|
|
||||||
} else if (motion.mode === "ghost_haunting" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "ghost_recover", phaseEndsAt: context.time + MOURNVEIL.recoverDuration };
|
|
||||||
} else if (motion.mode === "ghost_recover" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + MOURNVEIL.repeatDelay, slashLanes: [], mechanicHitIds: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
|
||||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.25, 14, context.damageMember);
|
|
||||||
return { boss, motion, party, events };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upcomingMournveilMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
|
||||||
if (motion.mode === "ghost_soul_cross") return { name: "Vine Scissors — first cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.crossWarning, urgent: true };
|
|
||||||
if (motion.mode === "ghost_soul_cross_followup") return { name: "Vine Scissors — rotated cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.followupWarning, urgent: true };
|
|
||||||
if (motion.mode === "ghost_haunting") return { name: "Haunting Rifts — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.riftWarning, urgent: true };
|
|
||||||
if (motion.mode === "ghost_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.recoverDuration, urgent: false };
|
|
||||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
|
||||||
return { name: motion.mechanicCount % 2 === 0 ? "Vine Scissors" : "Haunting Rifts", remaining, cycle: MOURNVEIL.repeatDelay + MOURNVEIL.crossWarning, urgent: remaining < 2.5 };
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import { clampToArena } from "../arena";
|
|
||||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
|
||||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
|
||||||
import type { BossId, BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
|
||||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
|
||||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
|
||||||
|
|
||||||
export const OBSIDIAN_RAM = {
|
|
||||||
firstAt: 5.5,
|
|
||||||
repeatDelay: 3.8,
|
|
||||||
chargeWarning: 1.45,
|
|
||||||
chargeSpeed: 11.5,
|
|
||||||
chargeDistance: 14,
|
|
||||||
chargeWidth: 2.5,
|
|
||||||
chargeDamage: 27,
|
|
||||||
quakeWarning: 1.35,
|
|
||||||
quakeRadius: 3.6,
|
|
||||||
quakeDamage: 31,
|
|
||||||
shatterWarning: 1.2,
|
|
||||||
shatterWidth: 1.25,
|
|
||||||
shatterDamage: 24,
|
|
||||||
recoverDuration: 0.7,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
|
|
||||||
|
|
||||||
export function createObsidianRamState(bossId: BossId = "bristlequake-boar"): BossState {
|
|
||||||
const definition = BOSS_DEFINITIONS[bossId];
|
|
||||||
return {
|
|
||||||
id: definition.id,
|
|
||||||
name: definition.name,
|
|
||||||
maxHp: definition.maxHp,
|
|
||||||
hp: definition.maxHp,
|
|
||||||
nextMeleeAt: 2.3,
|
|
||||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
|
||||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
|
||||||
brandCount: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createObsidianRamMotion(bossId: BossId = "bristlequake-boar"): BossMotionState {
|
|
||||||
return { ...createBaseMotion(bossId), position: [0, -6.8], nextMechanicAt: OBSIDIAN_RAM.firstAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
function laneAt(center: WorldPosition, angle: number, id: string): SlashLane {
|
|
||||||
const half = 9;
|
|
||||||
const dx = Math.sin(angle) * half;
|
|
||||||
const dz = Math.cos(angle) * half;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
start: [center[0] - dx, center[1] - dz],
|
|
||||||
end: [center[0] + dx, center[1] + dz],
|
|
||||||
width: OBSIDIAN_RAM.shatterWidth,
|
|
||||||
damage: OBSIDIAN_RAM.shatterDamage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function endpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
|
|
||||||
const angle = angleTo(start, target);
|
|
||||||
return clampToArena([
|
|
||||||
start[0] + Math.sin(angle) * OBSIDIAN_RAM.chargeDistance,
|
|
||||||
start[1] + Math.cos(angle) * OBSIDIAN_RAM.chargeDistance,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
|
|
||||||
const index = motion.mechanicCount % 3;
|
|
||||||
const mechanicCount = motion.mechanicCount + 1;
|
|
||||||
if (index === 0) {
|
|
||||||
const targetId = chooseLivingTarget(context.party, TARGETS, motion.mechanicCount);
|
|
||||||
const end = endpoint(motion.position, context.partyPositions[targetId]);
|
|
||||||
events.push({ at: context.time, message: `Destruction Rush locks onto ${memberName(context.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
|
|
||||||
return {
|
|
||||||
...motion,
|
|
||||||
mode: "ram_charge_telegraph" as const,
|
|
||||||
chargeTargetId: targetId,
|
|
||||||
chargeStart: [...motion.position] as WorldPosition,
|
|
||||||
chargeEnd: end,
|
|
||||||
chargeHitIds: [],
|
|
||||||
slashLanes: [{ id: `ram-charge-${mechanicCount}`, start: [...motion.position] as WorldPosition, end, width: OBSIDIAN_RAM.chargeWidth, damage: OBSIDIAN_RAM.chargeDamage }],
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: context.time + OBSIDIAN_RAM.chargeWarning,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
mechanicCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (index === 1) {
|
|
||||||
const activatesAt = context.time + OBSIDIAN_RAM.quakeWarning;
|
|
||||||
events.push({ at: context.time, message: "Ruin Quake! Leave the destruction circle.", tone: "danger", pulseKind: "boss" });
|
|
||||||
return {
|
|
||||||
...motion,
|
|
||||||
mode: "ram_quake" as const,
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: activatesAt + 0.25,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
mechanicCount,
|
|
||||||
hazards: [...motion.hazards, {
|
|
||||||
id: `ram-quake-${mechanicCount}`,
|
|
||||||
kind: "quake" as const,
|
|
||||||
center: [...motion.position] as WorldPosition,
|
|
||||||
radius: OBSIDIAN_RAM.quakeRadius,
|
|
||||||
activatesAt,
|
|
||||||
expiresAt: activatesAt + 0.3,
|
|
||||||
damage: OBSIDIAN_RAM.quakeDamage,
|
|
||||||
nextDamageAt: {},
|
|
||||||
resolved: false,
|
|
||||||
hitIds: [],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const targetId = chooseLivingTarget(context.party, TARGETS, motion.mechanicCount);
|
|
||||||
const aimed = angleTo(motion.position, context.partyPositions[targetId]);
|
|
||||||
events.push({ at: context.time, message: "Destruction Pulse! Step between the radial beams.", tone: "danger", pulseKind: "slash", targetId });
|
|
||||||
return {
|
|
||||||
...motion,
|
|
||||||
mode: "ram_shatter" as const,
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: context.time + OBSIDIAN_RAM.shatterWarning,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
mechanicCount,
|
|
||||||
mechanicHitIds: [],
|
|
||||||
slashLanes: [0, Math.PI / 3, -Math.PI / 3].map((offset, laneIndex) => laneAt(motion.position, aimed + offset, `ram-shatter-${mechanicCount}-${laneIndex}`)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveShatter(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
|
|
||||||
const hitIds: MemberId[] = [];
|
|
||||||
const party = context.party.map((member) => {
|
|
||||||
const lane = motion.slashLanes.find((entry) => pointToSegmentDistance(context.partyPositions[member.id], entry.start, entry.end) <= entry.width * 0.5);
|
|
||||||
if (member.hp <= 0 || !lane) return member;
|
|
||||||
hitIds.push(member.id);
|
|
||||||
events.push({ at: context.time, message: `${member.name} is struck by Destruction Pulse.`, tone: "danger", pulseKind: "slash", targetId: member.id });
|
|
||||||
return context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time);
|
|
||||||
});
|
|
||||||
return { party, hitIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advanceObsidianRamMechanics(context: BossMechanicContext): BossMechanicResult {
|
|
||||||
const boss = { ...context.boss };
|
|
||||||
let motion = cloneMotion(context.motion);
|
|
||||||
const events: BossMechanicResult["events"] = [];
|
|
||||||
let party = context.party;
|
|
||||||
|
|
||||||
if (motion.mode === "holding") {
|
|
||||||
returnBossToArenaCenter(motion, context.delta, 1.8);
|
|
||||||
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
|
|
||||||
} else if (motion.mode === "ram_charge_telegraph" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "ram_charging", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / OBSIDIAN_RAM.chargeSpeed };
|
|
||||||
events.push({ at: context.time, message: "Destruction Rush! Clear the lane.", tone: "danger", pulseKind: "charge" });
|
|
||||||
} else if (motion.mode === "ram_charging") {
|
|
||||||
const previous = [...motion.position] as WorldPosition;
|
|
||||||
motion.position = moveToward(motion.position, motion.chargeEnd, OBSIDIAN_RAM.chargeSpeed * context.delta);
|
|
||||||
party = party.map((member) => {
|
|
||||||
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > OBSIDIAN_RAM.chargeWidth * 0.5) return member;
|
|
||||||
motion.chargeHitIds.push(member.id);
|
|
||||||
return { ...context.damageMember(member, OBSIDIAN_RAM.chargeDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.55 };
|
|
||||||
});
|
|
||||||
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) motion = { ...motion, position: [...motion.chargeEnd], mode: "ram_recover", phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
|
|
||||||
} else if (motion.mode === "ram_shatter" && context.time >= motion.phaseEndsAt) {
|
|
||||||
const resolved = resolveShatter(motion, { ...context, party }, events);
|
|
||||||
party = resolved.party;
|
|
||||||
motion = { ...motion, mode: "ram_recover", mechanicHitIds: resolved.hitIds, phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
|
|
||||||
} else if (motion.mode === "ram_quake" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "ram_recover", phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
|
|
||||||
} else if (motion.mode === "ram_recover" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + OBSIDIAN_RAM.repeatDelay, slashLanes: [], chargeHitIds: [], mechanicHitIds: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
|
||||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.2, 15, context.damageMember);
|
|
||||||
return { boss, motion, party, events };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upcomingObsidianRamMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
|
||||||
if (motion.mode === "ram_charge_telegraph" || motion.mode === "ram_charging") return { name: "Destruction Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.chargeWarning, urgent: true };
|
|
||||||
if (motion.mode === "ram_quake") return { name: "Ruin Quake — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.quakeWarning, urgent: true };
|
|
||||||
if (motion.mode === "ram_shatter") return { name: "Destruction Pulse — find gap", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.shatterWarning, urgent: true };
|
|
||||||
if (motion.mode === "ram_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.recoverDuration, urgent: false };
|
|
||||||
const names = ["Destruction Rush", "Ruin Quake", "Destruction Pulse"];
|
|
||||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
|
||||||
return { name: names[motion.mechanicCount % 3], remaining, cycle: OBSIDIAN_RAM.repeatDelay + OBSIDIAN_RAM.chargeWarning, urgent: remaining < 2.5 };
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { clampToArena } from "../arena";
|
|
||||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
|
||||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
|
||||||
import type { BossId, BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
|
|
||||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
|
||||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
|
||||||
|
|
||||||
export const CINDERBACK = {
|
|
||||||
firstAt: 5,
|
|
||||||
repeatDelay: 3.6,
|
|
||||||
curlWarning: 1.25,
|
|
||||||
speed: 12.5,
|
|
||||||
distance: 13,
|
|
||||||
laneWidth: 2.25,
|
|
||||||
rushDamage: 22,
|
|
||||||
slamWarning: 1.3,
|
|
||||||
slamRadius: 3.1,
|
|
||||||
slamDamage: 29,
|
|
||||||
lavaRadius: 1.5,
|
|
||||||
lavaDamage: 5,
|
|
||||||
lavaDuration: 4.5,
|
|
||||||
recoverDuration: 0.75,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const TARGETS: readonly MemberId[] = ["orin", "nia", "aelia", "vale", "brann"];
|
|
||||||
|
|
||||||
export function createCinderbackState(bossId: BossId = "emberfox"): BossState {
|
|
||||||
const definition = BOSS_DEFINITIONS[bossId];
|
|
||||||
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.4, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCinderbackMotion(bossId: BossId = "emberfox"): BossMotionState {
|
|
||||||
return { ...createBaseMotion(bossId), position: [0, -6.4], nextMechanicAt: CINDERBACK.firstAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
function rushEnd(start: WorldPosition, target: WorldPosition) {
|
|
||||||
const angle = angleTo(start, target);
|
|
||||||
return clampToArena([start[0] + Math.sin(angle) * CINDERBACK.distance, start[1] + Math.cos(angle) * CINDERBACK.distance] as WorldPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rushLane(id: string, start: WorldPosition, end: WorldPosition): SlashLane {
|
|
||||||
return { id, start: [...start], end: [...end], width: CINDERBACK.laneWidth, damage: CINDERBACK.rushDamage };
|
|
||||||
}
|
|
||||||
|
|
||||||
function lavaPool(id: string, center: WorldPosition, at: number): CircleHazard {
|
|
||||||
return { id, kind: "lava_pool", center: [...center], radius: CINDERBACK.lavaRadius, activatesAt: at, expiresAt: at + CINDERBACK.lavaDuration, damage: CINDERBACK.lavaDamage, tickInterval: 0.8, nextDamageAt: {}, resolved: false, hitIds: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advanceCinderbackMechanics(context: BossMechanicContext): BossMechanicResult {
|
|
||||||
const boss = { ...context.boss };
|
|
||||||
let motion = cloneMotion(context.motion);
|
|
||||||
const events: BossMechanicResult["events"] = [];
|
|
||||||
let party = context.party;
|
|
||||||
|
|
||||||
if (motion.mode === "holding") {
|
|
||||||
returnBossToArenaCenter(motion, context.delta, 2);
|
|
||||||
if (context.time >= motion.nextMechanicAt) {
|
|
||||||
const count = motion.mechanicCount + 1;
|
|
||||||
if (motion.mechanicCount % 2 === 0) {
|
|
||||||
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
|
|
||||||
const end = rushEnd(motion.position, context.partyPositions[targetId]);
|
|
||||||
motion = { ...motion, mode: "cinderback_curl", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], chargeCount: 0, phaseStartedAt: context.time, phaseEndsAt: context.time + CINDERBACK.curlWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [rushLane(`ricochet-${count}-0`, motion.position, end)] };
|
|
||||||
events.push({ at: context.time, message: `${boss.name} dives toward ${memberName(party, targetId)}. Two rebounds incoming.`, tone: "danger", pulseKind: "charge", targetId });
|
|
||||||
} else {
|
|
||||||
const activatesAt = context.time + CINDERBACK.slamWarning;
|
|
||||||
const pools = [0, 1, 2].map((index) => {
|
|
||||||
const angle = (index / 3) * Math.PI * 2;
|
|
||||||
return lavaPool(`slam-lava-${count}-${index}`, clampToArena([motion.position[0] + Math.sin(angle) * 3.7, motion.position[1] + Math.cos(angle) * 3.7]), activatesAt);
|
|
||||||
});
|
|
||||||
motion = { ...motion, mode: "cinderback_slam", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.25, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, { id: `armor-slam-${count}`, kind: "quake", center: [...motion.position], radius: CINDERBACK.slamRadius, activatesAt, expiresAt: activatesAt + 0.3, damage: CINDERBACK.slamDamage, nextDamageAt: {}, resolved: false, hitIds: [] }, ...pools] };
|
|
||||||
events.push({ at: context.time, message: "Meteor slam! Clear the spreading flame.", tone: "danger", pulseKind: "boss" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "cinderback_curl" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "cinderback_ricochet", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / CINDERBACK.speed };
|
|
||||||
} else if (motion.mode === "cinderback_ricochet") {
|
|
||||||
const previous = [...motion.position] as WorldPosition;
|
|
||||||
motion.position = moveToward(motion.position, motion.chargeEnd, CINDERBACK.speed * context.delta);
|
|
||||||
party = party.map((member) => {
|
|
||||||
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > CINDERBACK.laneWidth * 0.5) return member;
|
|
||||||
motion.chargeHitIds.push(member.id);
|
|
||||||
return { ...context.damageMember(member, CINDERBACK.rushDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.4 };
|
|
||||||
});
|
|
||||||
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
|
|
||||||
motion.hazards.push(lavaPool(`ricochet-lava-${motion.mechanicCount}-${motion.chargeCount}`, motion.chargeEnd, context.time));
|
|
||||||
if (motion.chargeCount === 0) {
|
|
||||||
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount + 2);
|
|
||||||
const start = [...motion.chargeEnd] as WorldPosition;
|
|
||||||
const end = rushEnd(start, context.partyPositions[targetId]);
|
|
||||||
motion = { ...motion, position: start, chargeStart: start, chargeEnd: end, chargeTargetId: targetId, chargeHitIds: [], chargeCount: 1, phaseEndsAt: context.time + distance(start, end) / CINDERBACK.speed, slashLanes: [rushLane(`ricochet-${motion.mechanicCount}-1`, start, end)] };
|
|
||||||
events.push({ at: context.time, message: `Ricochet rush rebounds toward ${memberName(party, targetId)}!`, tone: "danger", pulseKind: "charge", targetId });
|
|
||||||
} else {
|
|
||||||
motion = { ...motion, position: [...motion.chargeEnd], mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "cinderback_slam" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
|
|
||||||
} else if (motion.mode === "cinderback_recover" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "holding", nextMechanicAt: context.time + CINDERBACK.repeatDelay, phaseEndsAt: 0, slashLanes: [], chargeHitIds: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
|
||||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.1, 14, context.damageMember);
|
|
||||||
return { boss, motion, party, events };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upcomingCinderbackMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
|
||||||
if (motion.mode === "cinderback_curl") return { name: "Ricochet Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.curlWarning, urgent: true };
|
|
||||||
if (motion.mode === "cinderback_ricochet") return { name: motion.chargeCount === 0 ? "First rebound" : "Second rebound", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
|
|
||||||
if (motion.mode === "cinderback_slam") return { name: "Meteor Slam — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.slamWarning, urgent: true };
|
|
||||||
if (motion.mode === "cinderback_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.recoverDuration, urgent: false };
|
|
||||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
|
||||||
return { name: motion.mechanicCount % 2 === 0 ? "Ricochet Rush" : "Meteor Slam", remaining, cycle: CINDERBACK.repeatDelay + CINDERBACK.curlWarning, urgent: remaining < 2.5 };
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { clampToArena } from "../arena";
|
|
||||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
|
||||||
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
|
|
||||||
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
|
|
||||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
|
||||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
|
||||||
|
|
||||||
export const SANDGLASS = {
|
|
||||||
firstAt: 5.4,
|
|
||||||
repeatDelay: 3.8,
|
|
||||||
burrowWarning: 1.3,
|
|
||||||
burrowSpeed: 10.8,
|
|
||||||
burrowDistance: 13,
|
|
||||||
burrowWidth: 2,
|
|
||||||
burrowDamage: 25,
|
|
||||||
eruptionWarning: 1.55,
|
|
||||||
eruptionRadius: 1.75,
|
|
||||||
eruptionDamage: 27,
|
|
||||||
hourglassRadius: 2.55,
|
|
||||||
hourglassDamage: 6,
|
|
||||||
hourglassDuration: 3.6,
|
|
||||||
recoverDuration: 0.7,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const TARGETS: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
|
|
||||||
const ERUPTION_TARGETS: readonly MemberId[][] = [["aelia", "nia", "orin"], ["brann", "vale", "aelia"], ["nia", "orin", "vale"]];
|
|
||||||
|
|
||||||
export function createSandglassState(): BossState {
|
|
||||||
const definition = BOSS_DEFINITIONS["sandglass-scorpion"];
|
|
||||||
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.2, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSandglassMotion(): BossMotionState {
|
|
||||||
return { ...createBaseMotion("sandglass-scorpion"), position: [0, -6.5], nextMechanicAt: SANDGLASS.firstAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
function burrowEnd(start: WorldPosition, target: WorldPosition) {
|
|
||||||
const angle = angleTo(start, target);
|
|
||||||
return clampToArena([start[0] + Math.sin(angle) * SANDGLASS.burrowDistance, start[1] + Math.cos(angle) * SANDGLASS.burrowDistance] as WorldPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
function eruption(id: string, center: WorldPosition, activatesAt: number): CircleHazard {
|
|
||||||
return { id, kind: "stinger_eruption", center: [...center], radius: SANDGLASS.eruptionRadius, activatesAt, expiresAt: activatesAt + 0.32, damage: SANDGLASS.eruptionDamage, nextDamageAt: {}, resolved: false, hitIds: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advanceSandglassMechanics(context: BossMechanicContext): BossMechanicResult {
|
|
||||||
const boss = { ...context.boss };
|
|
||||||
let motion = cloneMotion(context.motion);
|
|
||||||
const events: BossMechanicResult["events"] = [];
|
|
||||||
let party = context.party;
|
|
||||||
|
|
||||||
if (motion.mode === "holding") {
|
|
||||||
returnBossToArenaCenter(motion, context.delta, 2.1);
|
|
||||||
if (context.time >= motion.nextMechanicAt) {
|
|
||||||
const count = motion.mechanicCount + 1;
|
|
||||||
if (motion.mechanicCount % 2 === 0) {
|
|
||||||
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
|
|
||||||
const end = burrowEnd(motion.position, context.partyPositions[targetId]);
|
|
||||||
const lane: SlashLane = { id: `burrow-${count}`, start: [...motion.position], end, width: SANDGLASS.burrowWidth, damage: SANDGLASS.burrowDamage };
|
|
||||||
motion = { ...motion, mode: "sandglass_burrow_telegraph", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], phaseStartedAt: context.time, phaseEndsAt: context.time + SANDGLASS.burrowWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [lane] };
|
|
||||||
events.push({ at: context.time, message: `Burrow Rush tracks ${memberName(party, targetId)}. Cross the sand trail.`, tone: "danger", pulseKind: "charge", targetId });
|
|
||||||
} else {
|
|
||||||
const activatesAt = context.time + SANDGLASS.eruptionWarning;
|
|
||||||
const targets = ERUPTION_TARGETS[Math.floor(motion.mechanicCount / 2) % ERUPTION_TARGETS.length];
|
|
||||||
motion = { ...motion, mode: "sandglass_eruption", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.3, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, ...targets.map((targetId, index) => eruption(`stinger-${count}-${index}`, context.partyPositions[targetId], activatesAt))] };
|
|
||||||
events.push({ at: context.time, message: "Stinger Eruption! Leave the timed sand circles.", tone: "danger", pulseKind: "skyfall" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "sandglass_burrow_telegraph" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "sandglass_burrowing", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / SANDGLASS.burrowSpeed };
|
|
||||||
} else if (motion.mode === "sandglass_burrowing") {
|
|
||||||
const previous = [...motion.position] as WorldPosition;
|
|
||||||
motion.position = moveToward(motion.position, motion.chargeEnd, SANDGLASS.burrowSpeed * context.delta);
|
|
||||||
party = party.map((member) => {
|
|
||||||
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > SANDGLASS.burrowWidth * 0.5) return member;
|
|
||||||
motion.chargeHitIds.push(member.id);
|
|
||||||
return { ...context.damageMember(member, SANDGLASS.burrowDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.35 };
|
|
||||||
});
|
|
||||||
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) motion = { ...motion, position: [...motion.chargeEnd], mode: "sandglass_recover", phaseEndsAt: context.time + SANDGLASS.recoverDuration };
|
|
||||||
} else if (motion.mode === "sandglass_eruption" && context.time >= motion.phaseEndsAt) {
|
|
||||||
const center = clampToArena([motion.position[0], motion.position[1] + 3]);
|
|
||||||
const activatesAt = context.time + 0.9;
|
|
||||||
motion = { ...motion, mode: "sandglass_hourglass", phaseStartedAt: context.time, phaseEndsAt: activatesAt + SANDGLASS.hourglassDuration, hazards: [...motion.hazards, { id: `hourglass-${motion.mechanicCount}`, kind: "hourglass", center, radius: SANDGLASS.hourglassRadius, activatesAt, expiresAt: activatesAt + SANDGLASS.hourglassDuration, damage: SANDGLASS.hourglassDamage, tickInterval: 0.75, nextDamageAt: {}, resolved: false, hitIds: [] }] };
|
|
||||||
events.push({ at: context.time, message: "Hourglass zone turns active. Keep moving.", tone: "danger", pulseKind: "skyfall" });
|
|
||||||
} else if (motion.mode === "sandglass_hourglass" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "sandglass_recover", phaseEndsAt: context.time + SANDGLASS.recoverDuration };
|
|
||||||
} else if (motion.mode === "sandglass_recover" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + SANDGLASS.repeatDelay, slashLanes: [], chargeHitIds: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
|
||||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.15, 14, context.damageMember);
|
|
||||||
return { boss, motion, party, events };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upcomingSandglassMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
|
||||||
if (motion.mode === "sandglass_burrow_telegraph" || motion.mode === "sandglass_burrowing") return { name: "Burrow Rush — clear trail", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.burrowWarning, urgent: true };
|
|
||||||
if (motion.mode === "sandglass_eruption") return { name: "Stinger Eruption — move", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.eruptionWarning, urgent: true };
|
|
||||||
if (motion.mode === "sandglass_hourglass") return { name: "Hourglass zone active", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.hourglassDuration, urgent: true };
|
|
||||||
if (motion.mode === "sandglass_recover") return { name: "Chronarch exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.recoverDuration, urgent: false };
|
|
||||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
|
||||||
return { name: motion.mechanicCount % 2 === 0 ? "Burrow Rush" : "Hourglass Eruption", remaining, cycle: SANDGLASS.repeatDelay + SANDGLASS.burrowWarning, urgent: remaining < 2.5 };
|
|
||||||
}
|
|
||||||
@@ -39,19 +39,6 @@ export function returnBossToArenaCenter(motion: BossMotionState, delta: number,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBossStateFor(bossId: BossId, name: string, maxHp: number, nextMeleeAt: number): BossState {
|
|
||||||
return {
|
|
||||||
id: bossId,
|
|
||||||
name,
|
|
||||||
maxHp,
|
|
||||||
hp: maxHp,
|
|
||||||
nextMeleeAt,
|
|
||||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
|
||||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
|
||||||
brandCount: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCircleHazard({
|
export function createCircleHazard({
|
||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
@@ -92,6 +79,7 @@ export function createCircleHazard({
|
|||||||
export function createBaseMotion(bossId: BossId): BossMotionState {
|
export function createBaseMotion(bossId: BossId): BossMotionState {
|
||||||
return {
|
return {
|
||||||
bossId,
|
bossId,
|
||||||
|
activeMechanicId: null,
|
||||||
formationOffsetX: 0,
|
formationOffsetX: 0,
|
||||||
mode: "holding",
|
mode: "holding",
|
||||||
position: [0, -8.2],
|
position: [0, -8.2],
|
||||||
@@ -100,12 +88,9 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
|
|||||||
chargeTargetId: "nia",
|
chargeTargetId: "nia",
|
||||||
chargeHitIds: [],
|
chargeHitIds: [],
|
||||||
phaseEndsAt: 0,
|
phaseEndsAt: 0,
|
||||||
nextChargeAt: Number.POSITIVE_INFINITY,
|
|
||||||
chargeCount: 0,
|
chargeCount: 0,
|
||||||
chargesSincePounce: 0,
|
|
||||||
pounceTargetId: "aelia",
|
pounceTargetId: "aelia",
|
||||||
pounceCenter: [0, 4.5],
|
pounceCenter: [0, 4.5],
|
||||||
pounceCount: 0,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||||
mechanicCount: 0,
|
mechanicCount: 0,
|
||||||
phaseStartedAt: 0,
|
phaseStartedAt: 0,
|
||||||
@@ -118,7 +103,6 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
|
|||||||
breathEndAngle: 0,
|
breathEndAngle: 0,
|
||||||
hazards: [],
|
hazards: [],
|
||||||
slashLanes: [],
|
slashLanes: [],
|
||||||
nextPoolMechanicAt: 15,
|
|
||||||
poolMechanicCount: 0,
|
poolMechanicCount: 0,
|
||||||
poolTelegraphs: [],
|
poolTelegraphs: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
|
||||||
import { angleTo, pointInCone } from "../geometry";
|
|
||||||
import type { BossId, BossMotionState, BossState, MemberId } from "../types";
|
|
||||||
import { applyMelee, cloneMotion, createBaseMotion, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
|
||||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
|
||||||
|
|
||||||
export const SKY_SWEEPER_BREATH = {
|
|
||||||
firstAt: 6,
|
|
||||||
telegraphDuration: 2,
|
|
||||||
sweepDuration: 3.2,
|
|
||||||
range: 10.5,
|
|
||||||
halfAngle: Math.PI / 7,
|
|
||||||
sweepArc: Math.PI * 0.95,
|
|
||||||
tickDamage: 9,
|
|
||||||
tickInterval: 0.45,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const SKY_SWEEPER_SKYFALL = {
|
|
||||||
warning: 2,
|
|
||||||
stagger: 0.9,
|
|
||||||
radius: 1.8,
|
|
||||||
damage: 30,
|
|
||||||
fireDuration: 5,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const SKYFALL_TARGETS: readonly MemberId[][] = [
|
|
||||||
["nia", "orin", "vale"],
|
|
||||||
["aelia", "brann", "orin"],
|
|
||||||
["vale", "nia", "aelia"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createSkySweeperState(bossId: BossId): BossState {
|
|
||||||
const definition = BOSS_DEFINITIONS[bossId];
|
|
||||||
return {
|
|
||||||
id: bossId,
|
|
||||||
name: definition.name,
|
|
||||||
maxHp: definition.maxHp,
|
|
||||||
hp: definition.maxHp,
|
|
||||||
nextMeleeAt: 2.5,
|
|
||||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
|
||||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
|
||||||
brandCount: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSkySweeperMotion(bossId: BossId): BossMotionState {
|
|
||||||
return { ...createBaseMotion(bossId), position: [0, -2.8], nextMechanicAt: SKY_SWEEPER_BREATH.firstAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advanceSkySweeperMechanics(context: BossMechanicContext): BossMechanicResult {
|
|
||||||
const boss = { ...context.boss };
|
|
||||||
let motion = cloneMotion(context.motion);
|
|
||||||
let party = context.party;
|
|
||||||
const events: BossMechanicResult["events"] = [];
|
|
||||||
|
|
||||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
|
||||||
|
|
||||||
if (motion.mode === "holding") {
|
|
||||||
returnBossToArenaCenter(motion, context.delta, 1.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
|
|
||||||
if (motion.mechanicCount % 2 === 0) {
|
|
||||||
const aimedAngle = angleTo(motion.position, context.partyPositions.brann);
|
|
||||||
const direction = motion.mechanicCount % 4 === 0 ? 1 : -1;
|
|
||||||
const startAngle = aimedAngle - direction * SKY_SWEEPER_BREATH.sweepArc * 0.5;
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "breath_telegraph",
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: context.time + SKY_SWEEPER_BREATH.telegraphDuration,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
mechanicCount: motion.mechanicCount + 1,
|
|
||||||
breathAngle: startAngle,
|
|
||||||
breathStartAngle: startAngle,
|
|
||||||
breathEndAngle: startAngle + direction * SKY_SWEEPER_BREATH.sweepArc,
|
|
||||||
mechanicHitIds: [],
|
|
||||||
mechanicNextDamageAt: {},
|
|
||||||
};
|
|
||||||
events.push({ at: context.time, message: `${boss.name} gathers a sweeping storm breath. Rotate behind it!`, tone: "danger", pulseKind: "breath" });
|
|
||||||
} else {
|
|
||||||
const set = SKYFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % SKYFALL_TARGETS.length];
|
|
||||||
const hazards = set.map((memberId, index) => {
|
|
||||||
const activatesAt = context.time + SKY_SWEEPER_SKYFALL.warning + index * SKY_SWEEPER_SKYFALL.stagger;
|
|
||||||
return {
|
|
||||||
id: `skyfall-${motion.mechanicCount}-${index}`,
|
|
||||||
kind: "skyfall" as const,
|
|
||||||
center: [context.partyPositions[memberId][0], context.partyPositions[memberId][1]] as [number, number],
|
|
||||||
radius: SKY_SWEEPER_SKYFALL.radius,
|
|
||||||
activatesAt,
|
|
||||||
expiresAt: activatesAt + SKY_SWEEPER_SKYFALL.fireDuration,
|
|
||||||
damage: SKY_SWEEPER_SKYFALL.damage,
|
|
||||||
nextDamageAt: {},
|
|
||||||
resolved: false,
|
|
||||||
hitIds: [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "skyfall",
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: hazards[hazards.length - 1].activatesAt + 0.5,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
mechanicCount: motion.mechanicCount + 1,
|
|
||||||
hazards: [...motion.hazards, ...hazards],
|
|
||||||
};
|
|
||||||
events.push({ at: context.time, message: `${boss.name} takes flight. Three skyfalls incoming!`, tone: "danger", pulseKind: "skyfall", targetId: set[0] });
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "breath_sweeping",
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: context.time + SKY_SWEEPER_BREATH.sweepDuration,
|
|
||||||
breathAngle: motion.breathStartAngle,
|
|
||||||
};
|
|
||||||
events.push({ at: context.time, message: "Storm breath crosses the arena!", tone: "danger", pulseKind: "breath" });
|
|
||||||
} else if (motion.mode === "breath_sweeping") {
|
|
||||||
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / SKY_SWEEPER_BREATH.sweepDuration));
|
|
||||||
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
|
|
||||||
party = party.map((member) => {
|
|
||||||
if (member.hp <= 0) return member;
|
|
||||||
const exposed = pointInCone(context.partyPositions[member.id], motion.position, motion.breathAngle, SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.range);
|
|
||||||
if (!exposed) {
|
|
||||||
motion.mechanicNextDamageAt[member.id] = context.time;
|
|
||||||
return member;
|
|
||||||
}
|
|
||||||
let next = member;
|
|
||||||
let tickAt = motion.mechanicNextDamageAt[member.id] ?? motion.phaseStartedAt;
|
|
||||||
while (tickAt <= context.time + 0.001) {
|
|
||||||
next = context.damageMember(next, SKY_SWEEPER_BREATH.tickDamage, context.partyPositions[member.id], tickAt);
|
|
||||||
tickAt += SKY_SWEEPER_BREATH.tickInterval;
|
|
||||||
}
|
|
||||||
motion.mechanicNextDamageAt[member.id] = tickAt;
|
|
||||||
if (!motion.mechanicHitIds.includes(member.id)) {
|
|
||||||
motion.mechanicHitIds.push(member.id);
|
|
||||||
events.push({ at: context.time, message: `${member.name} is struck by storm breath.`, tone: "danger", pulseKind: "breath", targetId: member.id });
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
if (context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "skyfall" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
|
|
||||||
}
|
|
||||||
|
|
||||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.6, 17, context.damageMember);
|
|
||||||
return { boss, motion, party, events };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upcomingSkySweeperMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
|
||||||
void boss;
|
|
||||||
if (motion.mode === "breath_telegraph") return { name: "Storm Breath", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_BREATH.telegraphDuration, urgent: true };
|
|
||||||
if (motion.mode === "breath_sweeping") return { name: "Rotate behind", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_BREATH.sweepDuration, urgent: true };
|
|
||||||
if (motion.mode === "skyfall") return { name: "Stormfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_SKYFALL.warning + SKY_SWEEPER_SKYFALL.stagger * 2, urgent: true };
|
|
||||||
const nextIsBreath = motion.mechanicCount % 2 === 0;
|
|
||||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
|
||||||
return { name: nextIsBreath ? "Storm Breath" : "Stormfall", remaining, cycle: 8, urgent: remaining < 2.5 };
|
|
||||||
}
|
|
||||||
@@ -22,8 +22,6 @@ export interface BossMechanicContext {
|
|||||||
partyPositions: Record<MemberId, WorldPosition>;
|
partyPositions: Record<MemberId, WorldPosition>;
|
||||||
time: number;
|
time: number;
|
||||||
delta: number;
|
delta: number;
|
||||||
/** Shared pool mechanics are reserved for solo encounters to avoid unreadable overlap in multi-boss fights. */
|
|
||||||
allowPooledMechanics?: boolean;
|
|
||||||
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number, kind?: "direct" | "hazard") => PartyMember;
|
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number, kind?: "direct" | "hazard") => PartyMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
|
||||||
import { distance } from "../geometry";
|
|
||||||
import type { BossId, BossMotionState, BossState, MemberId } from "../types";
|
|
||||||
import { applyMelee, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
|
|
||||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
|
||||||
|
|
||||||
export const VEXA_TETHER = {
|
|
||||||
firstAt: 6,
|
|
||||||
duration: 4.5,
|
|
||||||
breakDistance: 6.8,
|
|
||||||
failureDamage: 24,
|
|
||||||
rootDuration: 1.4,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const VEXA_VENOM = {
|
|
||||||
duration: 10,
|
|
||||||
tickDamage: 5,
|
|
||||||
castDuration: 2.5,
|
|
||||||
poolRadius: 2,
|
|
||||||
poolDuration: 7,
|
|
||||||
poolDamage: 14,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const TETHER_PAIRS: readonly (readonly [MemberId, MemberId])[] = [
|
|
||||||
["brann", "vale"],
|
|
||||||
["nia", "orin"],
|
|
||||||
["aelia", "nia"],
|
|
||||||
];
|
|
||||||
const VENOM_TARGETS: readonly MemberId[][] = [
|
|
||||||
["nia", "vale"],
|
|
||||||
["orin", "brann"],
|
|
||||||
["aelia", "vale"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createVexaState(bossId: BossId = "broodfang-spider"): BossState {
|
|
||||||
const definition = BOSS_DEFINITIONS[bossId];
|
|
||||||
return {
|
|
||||||
id: bossId,
|
|
||||||
name: definition.name,
|
|
||||||
maxHp: definition.maxHp,
|
|
||||||
hp: definition.maxHp,
|
|
||||||
nextMeleeAt: 2.5,
|
|
||||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
|
||||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
|
||||||
brandCount: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createVexaMotion(bossId: BossId = "broodfang-spider"): BossMotionState {
|
|
||||||
return { ...createBaseMotion(bossId), position: [0, -7.4], nextMechanicAt: VEXA_TETHER.firstAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advanceVexaMechanics(context: BossMechanicContext): BossMechanicResult {
|
|
||||||
const boss = { ...context.boss };
|
|
||||||
let motion = cloneMotion(context.motion);
|
|
||||||
let party = context.party;
|
|
||||||
const events: BossMechanicResult["events"] = [];
|
|
||||||
|
|
||||||
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
|
|
||||||
|
|
||||||
if (motion.mode === "holding") {
|
|
||||||
returnBossToArenaCenter(motion, context.delta, 2.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
|
|
||||||
if (motion.mechanicCount % 2 === 0) {
|
|
||||||
const pair = TETHER_PAIRS[Math.floor(motion.mechanicCount / 2) % TETHER_PAIRS.length];
|
|
||||||
const livingPair = pair.filter((memberId) => party.some((member) => member.id === memberId && member.hp > 0));
|
|
||||||
if (livingPair.length === 2) {
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "tethering",
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: context.time + VEXA_TETHER.duration,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
tetherIds: [...livingPair],
|
|
||||||
tetherBreakDistance: VEXA_TETHER.breakDistance,
|
|
||||||
mechanicCount: motion.mechanicCount + 1,
|
|
||||||
};
|
|
||||||
events.push({ at: context.time, message: `${boss.name} binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const targetSet = VENOM_TARGETS[Math.floor(motion.mechanicCount / 2) % VENOM_TARGETS.length];
|
|
||||||
const targets = targetSet.filter((memberId) => party.some((member) => member.id === memberId && member.hp > 0));
|
|
||||||
party = party.map((member) => targets.includes(member.id)
|
|
||||||
? {
|
|
||||||
...member,
|
|
||||||
debuffs: [...member.debuffs, {
|
|
||||||
id: `widow-venom-${motion.mechanicCount}-${member.id}`,
|
|
||||||
name: "Widow Venom",
|
|
||||||
expiresAt: context.time + VEXA_VENOM.duration,
|
|
||||||
nextTickAt: context.time + 1,
|
|
||||||
tickDamage: VEXA_VENOM.tickDamage,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
: member);
|
|
||||||
motion = {
|
|
||||||
...motion,
|
|
||||||
mode: "venom_cast",
|
|
||||||
phaseStartedAt: context.time,
|
|
||||||
phaseEndsAt: context.time + VEXA_VENOM.castDuration,
|
|
||||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
|
||||||
mechanicCount: motion.mechanicCount + 1,
|
|
||||||
};
|
|
||||||
events.push({ at: context.time, message: `${boss.name} injects Widow Venom. Move away before cleansing!`, tone: "danger", pulseKind: "venom", targetId: targets[0] });
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "tethering") {
|
|
||||||
const [first, second] = motion.tetherIds;
|
|
||||||
if (!first || !second || distance(context.partyPositions[first], context.partyPositions[second]) >= motion.tetherBreakDistance) {
|
|
||||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, tetherIds: [], nextMechanicAt: context.time + 4 };
|
|
||||||
events.push({ at: context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
|
|
||||||
} else if (context.time >= motion.phaseEndsAt) {
|
|
||||||
party = party.map((member) => motion.tetherIds.includes(member.id)
|
|
||||||
? {
|
|
||||||
...context.damageMember(member, VEXA_TETHER.failureDamage, context.partyPositions[member.id], context.time),
|
|
||||||
knockedUntil: context.time + VEXA_TETHER.rootDuration,
|
|
||||||
}
|
|
||||||
: member);
|
|
||||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, tetherIds: [], nextMechanicAt: context.time + 4 };
|
|
||||||
events.push({ at: context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
|
|
||||||
}
|
|
||||||
} else if (motion.mode === "venom_cast" && context.time >= motion.phaseEndsAt) {
|
|
||||||
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + 4 };
|
|
||||||
}
|
|
||||||
|
|
||||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.7, 13, context.damageMember);
|
|
||||||
return { boss, motion, party, events };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dropVexaVenomPool(
|
|
||||||
motion: BossMotionState,
|
|
||||||
memberId: MemberId,
|
|
||||||
center: [number, number],
|
|
||||||
time: number,
|
|
||||||
) {
|
|
||||||
const next = cloneMotion(motion);
|
|
||||||
next.hazards.push({
|
|
||||||
id: `venom-pool-${memberId}-${time.toFixed(2)}`,
|
|
||||||
kind: "venom_pool",
|
|
||||||
center: [center[0], center[1]],
|
|
||||||
radius: VEXA_VENOM.poolRadius,
|
|
||||||
activatesAt: time + 0.25,
|
|
||||||
expiresAt: time + VEXA_VENOM.poolDuration,
|
|
||||||
damage: VEXA_VENOM.poolDamage,
|
|
||||||
tickInterval: 1,
|
|
||||||
nextDamageAt: {},
|
|
||||||
resolved: false,
|
|
||||||
hitIds: [],
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upcomingVexaMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
|
|
||||||
void boss;
|
|
||||||
if (motion.mode === "tethering") return { name: "Break Binding Web", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: VEXA_TETHER.duration, urgent: true };
|
|
||||||
if (motion.mode === "venom_cast") return { name: "Move, then Purify", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: VEXA_VENOM.castDuration, urgent: true };
|
|
||||||
const nextIsTether = motion.mechanicCount % 2 === 0;
|
|
||||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
|
||||||
return { name: nextIsTether ? "Binding Web" : "Venom Purge", remaining, cycle: 8, urgent: remaining < 2.5 };
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+1
-1
@@ -73,7 +73,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: [
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { BULL_CHARGE } from "./bossMechanics";
|
|
||||||
import { clampToArena } from "./arena";
|
import { clampToArena } from "./arena";
|
||||||
import { SKY_SWEEPER_BREATH } from "./bosses/skySweeper";
|
import { BULL_CHARGE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool";
|
||||||
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
||||||
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
|
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
|
||||||
|
|
||||||
@@ -37,24 +36,11 @@ const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
|
|||||||
const LANE_EVADE_MODES: readonly BossMotionState["mode"][] = [
|
const LANE_EVADE_MODES: readonly BossMotionState["mode"][] = [
|
||||||
"mantis_line_telegraph",
|
"mantis_line_telegraph",
|
||||||
"mantis_cross_telegraph",
|
"mantis_cross_telegraph",
|
||||||
"ram_charge_telegraph",
|
|
||||||
"ram_charging",
|
|
||||||
"ram_shatter",
|
|
||||||
"cinderback_curl",
|
|
||||||
"cinderback_ricochet",
|
|
||||||
"sandglass_burrow_telegraph",
|
|
||||||
"sandglass_burrowing",
|
|
||||||
"crab_scuttle_telegraph",
|
|
||||||
"crab_scuttling",
|
|
||||||
"ghost_soul_cross",
|
|
||||||
"ghost_soul_cross_followup",
|
|
||||||
];
|
];
|
||||||
const FORMATION_MODES: readonly BossMotionState["mode"][] = [
|
const FORMATION_MODES: readonly BossMotionState["mode"][] = [
|
||||||
"holding", "telegraph", "tethering", "venom_cast", "skyfall", "mantis_sidestep", "mantis_recover",
|
"holding", "telegraph", "tethering", "venom_cast", "skyfall", "cinderback_slam", "sandglass_hourglass", "golem_shockwave", "golem_crownfall",
|
||||||
"ram_quake", "ram_recover", "cinderback_slam", "cinderback_recover", "sandglass_eruption", "sandglass_hourglass", "sandglass_recover",
|
|
||||||
"crab_tidal_burst", "crab_recover", "ghost_haunting", "ghost_recover", "golem_shockwave", "golem_crownfall", "golem_recover",
|
|
||||||
];
|
];
|
||||||
const DASH_MODES: readonly BossMotionState["mode"][] = ["telegraph", "charging", "ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"];
|
const DASH_MODES: readonly BossMotionState["mode"][] = ["telegraph", "charging"];
|
||||||
|
|
||||||
const FORMATION_SLOTS: Record<AiMemberId, WorldPosition> = {
|
const FORMATION_SLOTS: Record<AiMemberId, WorldPosition> = {
|
||||||
// Bosses face Brann during normal uptime, making positive Z their front.
|
// Bosses face Brann during normal uptime, making positive Z their front.
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { highestRoguelikeRoundAfterDefeat } from "./hunterStats";
|
||||||
|
|
||||||
|
describe("roguelike hunter records", () => {
|
||||||
|
it("records the reached defeat round without lowering a previous best", () => {
|
||||||
|
expect(highestRoguelikeRoundAfterDefeat(0, 1)).toBe(1);
|
||||||
|
expect(highestRoguelikeRoundAfterDefeat(7, 12)).toBe(12);
|
||||||
|
expect(highestRoguelikeRoundAfterDefeat(12, 7)).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes invalid and fractional round values", () => {
|
||||||
|
expect(highestRoguelikeRoundAfterDefeat(Number.NaN, Number.NaN)).toBe(1);
|
||||||
|
expect(highestRoguelikeRoundAfterDefeat(4.9, 8.9)).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function highestRoguelikeRoundAfterDefeat(currentRecord: number, reachedRound: number): number {
|
||||||
|
const normalizedRecord = Math.max(0, Math.floor(Number(currentRecord) || 0));
|
||||||
|
const normalizedRound = Math.max(1, Math.floor(Number(reachedRound) || 1));
|
||||||
|
return Math.max(normalizedRecord, normalizedRound);
|
||||||
|
}
|
||||||
@@ -7,15 +7,20 @@ import {
|
|||||||
equipActiveInfusion,
|
equipActiveInfusion,
|
||||||
equipPassiveInfusion,
|
equipPassiveInfusion,
|
||||||
infusionCosts,
|
infusionCosts,
|
||||||
|
PASSIVE_INFUSIONS,
|
||||||
passiveInfusionUnlocked,
|
passiveInfusionUnlocked,
|
||||||
} from "./infusions";
|
} from "./infusions";
|
||||||
import { bossGroupDrop, groupDrop, type MaterialStack } from "./loot";
|
import { bossGroupDrop, groupDrop, type MaterialStack } from "./loot";
|
||||||
|
import { RUN_BUFF_ORDER } from "../roguelike";
|
||||||
|
|
||||||
function stack(item: ReturnType<typeof groupDrop>, quantity: number): MaterialStack {
|
function stack(item: ReturnType<typeof groupDrop>, quantity: number): MaterialStack {
|
||||||
return { id: item.id, name: item.name, rarity: item.rarity, itemLevel: item.itemLevel, glyph: item.glyph, quantity };
|
return { id: item.id, name: item.name, rarity: item.rarity, itemLevel: item.itemLevel, glyph: item.glyph, quantity };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("IWT2-style gear infusions", () => {
|
describe("IWT2-style gear infusions", () => {
|
||||||
|
it("offers all 18 roguelike buffs as passive infusion choices", () => {
|
||||||
|
expect(PASSIVE_INFUSIONS.map((passive) => passive.id)).toEqual(RUN_BUFF_ORDER);
|
||||||
|
});
|
||||||
it("requires a +5 anchor and atomically spends five Ascendant plus five Mythic group drops", () => {
|
it("requires a +5 anchor and atomically spends five Ascendant plus five Mythic group drops", () => {
|
||||||
const progress = createDefaultGearProgress();
|
const progress = createDefaultGearProgress();
|
||||||
progress.brann.slots.weapon.level = 5;
|
progress.brann.slots.weapon.level = 5;
|
||||||
@@ -54,9 +59,10 @@ describe("IWT2-style gear infusions", () => {
|
|||||||
const progress = createDefaultGearProgress();
|
const progress = createDefaultGearProgress();
|
||||||
progress.vale.slots.feet.level = 10;
|
progress.vale.slots.feet.level = 10;
|
||||||
expect(passiveInfusionUnlocked(progress)).toBe(true);
|
expect(passiveInfusionUnlocked(progress)).toBe(true);
|
||||||
const infused = equipPassiveInfusion(progress, "priest", "deep-wells");
|
const infused = equipPassiveInfusion(progress, "priest", "mend-efficiency");
|
||||||
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "bulldrome", "encounter", infused);
|
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "bulldrome", "encounter", infused);
|
||||||
expect(useGameStore.getState().maxMana).toBe(120);
|
expect(useGameStore.getState().runModifiers.mendManaMultiplier).toBe(0.75);
|
||||||
expect(useGameStore.getState().runBuffs).toEqual([]);
|
expect(useGameStore.getState().runBuffRanks).toEqual({});
|
||||||
|
expect(useGameStore.getState().passiveRunBuffId).toBe("mend-efficiency");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+77
-11
@@ -1,12 +1,19 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { freshParty } from "./data";
|
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
||||||
import {
|
import {
|
||||||
applyRunBuffsToParty,
|
RUN_BUFF_ORDER,
|
||||||
|
RUN_BUFFS,
|
||||||
bossHealthMultiplier,
|
bossHealthMultiplier,
|
||||||
runHealingMultiplier,
|
compileRunModifiers,
|
||||||
runMaxMana,
|
effectiveRunBuffRank,
|
||||||
|
formatRunBuffEffect,
|
||||||
|
increaseRunBuffRank,
|
||||||
selectRandomBossPair,
|
selectRandomBossPair,
|
||||||
|
selectRogueTrialsBosses,
|
||||||
|
selectUnseenBosses,
|
||||||
|
selectRunBuffDraft,
|
||||||
} from "./roguelike";
|
} from "./roguelike";
|
||||||
|
import type { RunBuffRanks } from "./types";
|
||||||
|
|
||||||
describe("roguelike progression", () => {
|
describe("roguelike progression", () => {
|
||||||
it("adds 10% base boss HP per completed round", () => {
|
it("adds 10% base boss HP per completed round", () => {
|
||||||
@@ -15,14 +22,51 @@ describe("roguelike progression", () => {
|
|||||||
expect(bossHealthMultiplier(5)).toBe(1.4);
|
expect(bossHealthMultiplier(5)).toBe(1.4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stacks each persistent buff independently", () => {
|
it("defines 18 unique infusion-eligible ability buffs without legacy ids", () => {
|
||||||
const buffs = ["vital-bloom", "vital-bloom", "deep-wells", "restoring-grace"] as const;
|
expect(RUN_BUFF_ORDER).toHaveLength(18);
|
||||||
const party = applyRunBuffsToParty(freshParty("priest", "Aelia"), buffs);
|
expect(new Set(RUN_BUFF_ORDER)).toHaveLength(18);
|
||||||
|
expect(RUN_BUFF_ORDER.every((id) => RUN_BUFFS[id].infusionEligible)).toBe(true);
|
||||||
|
expect(RUN_BUFF_ORDER).not.toContain("vital-bloom");
|
||||||
|
expect(RUN_BUFF_ORDER).not.toContain("deep-wells");
|
||||||
|
expect(RUN_BUFF_ORDER).not.toContain("restoring-grace");
|
||||||
|
});
|
||||||
|
|
||||||
expect(party[0].maxHp).toBe(124);
|
it("compiles capped ranks and overlays one passive infusion rank", () => {
|
||||||
expect(party[1].maxHp).toBe(186);
|
const ranks: RunBuffRanks = {
|
||||||
expect(runMaxMana(buffs)).toBe(120);
|
"mend-echo": 9,
|
||||||
expect(runHealingMultiplier(buffs)).toBe(1.15);
|
"mend-efficiency": 2,
|
||||||
|
"renew-duration": 2,
|
||||||
|
"shield-guard": 3,
|
||||||
|
"purify-renew": 1,
|
||||||
|
"radiance-shield": 2,
|
||||||
|
"barrier-regen": 3,
|
||||||
|
};
|
||||||
|
const modifiers = compileRunModifiers(ranks, "mend-efficiency");
|
||||||
|
|
||||||
|
expect(modifiers.mendExtraTargets).toBe(3);
|
||||||
|
expect(modifiers.mendManaMultiplier).toBeCloseTo(0.75 ** 3);
|
||||||
|
expect(modifiers.renewDurationBonus).toBe(4);
|
||||||
|
expect(modifiers.shieldDamageTakenMultiplier).toBeCloseTo(0.76);
|
||||||
|
expect(modifiers.purifyAppliesRenew).toBe(true);
|
||||||
|
expect(modifiers.radianceAbsorb).toBe(18);
|
||||||
|
expect(modifiers.barrierHealingPerSecond).toBe(9);
|
||||||
|
expect(effectiveRunBuffRank(ranks, "mend-efficiency", "mend-efficiency")).toBe(3);
|
||||||
|
expect(formatRunBuffEffect("mend-efficiency", 3)).toBe("58% less Mend mana cost");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments earned ranks without exceeding each buff cap", () => {
|
||||||
|
const first = increaseRunBuffRank({}, "purify-renew");
|
||||||
|
const capped = increaseRunBuffRank(first, "purify-renew");
|
||||||
|
expect(first["purify-renew"]).toBe(1);
|
||||||
|
expect(capped["purify-renew"]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws unique eligible choices and handles one or zero remaining buffs", () => {
|
||||||
|
expect(selectRunBuffDraft({}, null, () => 0)).toEqual(RUN_BUFF_ORDER.slice(0, 3));
|
||||||
|
const maxed = Object.fromEntries(RUN_BUFF_ORDER.map((id) => [id, RUN_BUFFS[id].maxRank])) as RunBuffRanks;
|
||||||
|
maxed["mend-efficiency"] = 2;
|
||||||
|
expect(selectRunBuffDraft(maxed, null, () => 0)).toEqual(["mend-efficiency"]);
|
||||||
|
expect(selectRunBuffDraft(maxed, "mend-efficiency", () => 0)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects a distinct pair that excludes both bosses from the prior round", () => {
|
it("selects a distinct pair that excludes both bosses from the prior round", () => {
|
||||||
@@ -34,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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+212
-38
@@ -1,68 +1,242 @@
|
|||||||
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
|
||||||
import type { BossId, PartyMember, RunBuffId } from "./types";
|
import type { AbilityId, BossId, RunBuffId, RunBuffRanks } from "./types";
|
||||||
|
|
||||||
|
export type RunBuffEffectKind =
|
||||||
|
| "extra-target"
|
||||||
|
| "mana-cost"
|
||||||
|
| "cast-time"
|
||||||
|
| "duration"
|
||||||
|
| "healing"
|
||||||
|
| "absorb"
|
||||||
|
| "damage-reduction"
|
||||||
|
| "trigger-renew"
|
||||||
|
| "trigger-shield"
|
||||||
|
| "chain-cleanse"
|
||||||
|
| "cooldown"
|
||||||
|
| "barrier-healing";
|
||||||
|
|
||||||
export interface RunBuffDefinition {
|
export interface RunBuffDefinition {
|
||||||
id: RunBuffId;
|
id: RunBuffId;
|
||||||
|
abilityId: AbilityId;
|
||||||
|
effectKind: RunBuffEffectKind;
|
||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
accent: string;
|
accent: string;
|
||||||
|
maxRank: 1 | 3;
|
||||||
|
infusionEligible: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RUN_BUFF_ORDER: readonly RunBuffId[] = ["vital-bloom", "deep-wells", "restoring-grace"];
|
export interface CompiledRunModifiers {
|
||||||
|
mendExtraTargets: number;
|
||||||
|
mendManaMultiplier: number;
|
||||||
|
mendCastTimeMultiplier: number;
|
||||||
|
renewExtraTargets: number;
|
||||||
|
renewDurationBonus: number;
|
||||||
|
renewHealingMultiplier: number;
|
||||||
|
shieldExtraTargets: number;
|
||||||
|
shieldAbsorbMultiplier: number;
|
||||||
|
shieldDamageTakenMultiplier: number;
|
||||||
|
purifyAppliesRenew: boolean;
|
||||||
|
purifyAppliesShield: boolean;
|
||||||
|
purifyExtraTargets: number;
|
||||||
|
radianceCooldownMultiplier: number;
|
||||||
|
radianceAppliesRenew: boolean;
|
||||||
|
radianceAbsorb: number;
|
||||||
|
barrierCooldownMultiplier: number;
|
||||||
|
barrierDurationBonus: number;
|
||||||
|
barrierHealingPerSecond: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RUN_BUFF_ORDER: readonly RunBuffId[] = [
|
||||||
|
"mend-echo",
|
||||||
|
"mend-efficiency",
|
||||||
|
"mend-cast-speed",
|
||||||
|
"renew-spread",
|
||||||
|
"renew-duration",
|
||||||
|
"renew-potency",
|
||||||
|
"shield-echo",
|
||||||
|
"shield-potency",
|
||||||
|
"shield-guard",
|
||||||
|
"purify-renew",
|
||||||
|
"purify-shield",
|
||||||
|
"purify-chain",
|
||||||
|
"radiance-cooldown",
|
||||||
|
"radiance-renew",
|
||||||
|
"radiance-shield",
|
||||||
|
"barrier-cooldown",
|
||||||
|
"barrier-duration",
|
||||||
|
"barrier-regen",
|
||||||
|
];
|
||||||
|
|
||||||
|
const buff = (
|
||||||
|
id: RunBuffId,
|
||||||
|
abilityId: AbilityId,
|
||||||
|
effectKind: RunBuffEffectKind,
|
||||||
|
name: string,
|
||||||
|
icon: string,
|
||||||
|
summary: string,
|
||||||
|
detail: string,
|
||||||
|
accent: string,
|
||||||
|
maxRank: 1 | 3 = 3,
|
||||||
|
): RunBuffDefinition => ({ id, abilityId, effectKind, name, icon, summary, detail, accent, maxRank, infusionEligible: true });
|
||||||
|
|
||||||
export const RUN_BUFFS: Record<RunBuffId, RunBuffDefinition> = {
|
export const RUN_BUFFS: Record<RunBuffId, RunBuffDefinition> = {
|
||||||
"vital-bloom": {
|
"mend-echo": buff("mend-echo", "mend", "extra-target", "Echoing", "+", "+1 secondary ally", "Mend heals another injured ally for 50% power per rank.", "#f2d690"),
|
||||||
id: "vital-bloom",
|
"mend-efficiency": buff("mend-efficiency", "mend", "mana-cost", "Efficient", "▽", "−25% mana cost", "Mend mana cost is multiplied by 0.75 per rank, rounded up.", "#72c8ef"),
|
||||||
name: "Vital Bloom",
|
"mend-cast-speed": buff("mend-cast-speed", "mend", "cast-time", "Swift", "»", "−25% cast time", "Mend cast time is multiplied by 0.75 per rank.", "#d8b4ff"),
|
||||||
icon: "♧",
|
"renew-spread": buff("renew-spread", "renew", "extra-target", "Spreading", "✣", "+1 injured ally", "Direct Renew casts affect another injured ally per rank.", "#71df9c"),
|
||||||
summary: "+12% party max HP",
|
"renew-duration": buff("renew-duration", "renew", "duration", "Enduring", "◷", "+2s duration", "Every Renew effect lasts 2 seconds longer per rank.", "#83d9aa"),
|
||||||
detail: "Stacks each time chosen. New round starts at full health.",
|
"renew-potency": buff("renew-potency", "renew", "healing", "Potent", "↑", "+20% tick healing", "Every Renew tick heals 20% more per rank.", "#a8e875"),
|
||||||
accent: "#74d18c",
|
"shield-echo": buff("shield-echo", "shield", "extra-target", "Echoing Aegis", "◇", "+1 secondary ally", "Shield another injured ally for 50% power per rank.", "#75c9ff"),
|
||||||
},
|
"shield-potency": buff("shield-potency", "shield", "absorb", "Reinforced Aegis", "⬡", "+25% absorption", "All healer-created absorption is 25% stronger per rank.", "#61b9ee"),
|
||||||
"deep-wells": {
|
"shield-guard": buff("shield-guard", "shield", "damage-reduction", "Guardian Aegis", "▣", "−8% shielded damage", "Targets with absorption take 8% less incoming damage per rank.", "#8baeff"),
|
||||||
id: "deep-wells",
|
"purify-renew": buff("purify-renew", "purify", "trigger-renew", "Cleansing Renewal", "✧", "Purify applies Renew", "Every ally cleansed by Purify also gains Renew.", "#b58cff", 1),
|
||||||
name: "Deep Wells",
|
"purify-shield": buff("purify-shield", "purify", "trigger-shield", "Purifying Ward", "◈", "Purify grants 50% Shield", "Every ally cleansed by Purify gains half-strength absorption.", "#9f9aff", 1),
|
||||||
icon: "◇",
|
"purify-chain": buff("purify-chain", "purify", "chain-cleanse", "Mass Purification", "✦", "+1 cleansed ally", "Purify also cleanses the most injured other debuffed ally.", "#d4a7ff", 1),
|
||||||
summary: "+20 maximum mana",
|
"radiance-cooldown": buff("radiance-cooldown", "radiance", "cooldown", "Quickened Radiance", "☀", "−20% cooldown", "Radiance cooldown is multiplied by 0.8 per rank.", "#ffd66b"),
|
||||||
detail: "Stacks each time chosen. New round starts with full mana.",
|
"radiance-renew": buff("radiance-renew", "radiance", "trigger-renew", "Radiant Renewal", "❈", "Radiance applies Renew", "Radiance applies Renew to every living party member.", "#d4e978", 1),
|
||||||
accent: "#69baff",
|
"radiance-shield": buff("radiance-shield", "radiance", "absorb", "Radiant Aegis", "◎", "+9 party absorption", "Radiance grants 9 base absorption to every living ally per rank.", "#ffe58c"),
|
||||||
},
|
"barrier-cooldown": buff("barrier-cooldown", "barrier", "cooldown", "Hallowed Ground", "◉", "−20% cooldown", "Barrier cooldown is multiplied by 0.8 per rank.", "#e7cb62"),
|
||||||
"restoring-grace": {
|
"barrier-duration": buff("barrier-duration", "barrier", "duration", "Lingering Barrier", "⌛", "+2s duration", "Barrier remains active 2 seconds longer per rank.", "#cdbd69"),
|
||||||
id: "restoring-grace",
|
"barrier-regen": buff("barrier-regen", "barrier", "barrier-healing", "Restorative Ground", "✚", "+3 healing per second", "Living allies inside Barrier heal every second per rank.", "#84d69a"),
|
||||||
name: "Restoring Grace",
|
|
||||||
icon: "✦",
|
|
||||||
summary: "+15% healing done",
|
|
||||||
detail: "Strengthens Mend, Renew, and Radiance. Stacks additively.",
|
|
||||||
accent: "#f1d479",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function countRunBuff(buffs: readonly RunBuffId[], buffId: RunBuffId) {
|
export function runBuffRank(ranks: RunBuffRanks, buffId: RunBuffId): number {
|
||||||
return buffs.reduce((count, current) => count + Number(current === buffId), 0);
|
return Math.max(0, Math.min(RUN_BUFFS[buffId].maxRank, Math.floor(ranks[buffId] ?? 0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyRunBuffsToParty(party: PartyMember[], buffs: readonly RunBuffId[]) {
|
export function effectiveRunBuffRank(ranks: RunBuffRanks, buffId: RunBuffId, passiveInfusionId: RunBuffId | null = null): number {
|
||||||
const vitalityMultiplier = 1 + countRunBuff(buffs, "vital-bloom") * 0.12;
|
return Math.min(RUN_BUFFS[buffId].maxRank, runBuffRank(ranks, buffId) + Number(passiveInfusionId === buffId));
|
||||||
return party.map((member) => {
|
|
||||||
const maxHp = Math.round(member.maxHp * vitalityMultiplier);
|
|
||||||
return { ...member, maxHp, hp: maxHp };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runMaxMana(buffs: readonly RunBuffId[]) {
|
export function increaseRunBuffRank(ranks: RunBuffRanks, buffId: RunBuffId): RunBuffRanks {
|
||||||
return 100 + countRunBuff(buffs, "deep-wells") * 20;
|
const current = runBuffRank(ranks, buffId);
|
||||||
|
if (current >= RUN_BUFFS[buffId].maxRank) return { ...ranks };
|
||||||
|
return { ...ranks, [buffId]: current + 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runHealingMultiplier(buffs: readonly RunBuffId[]) {
|
export function selectRunBuffDraft(
|
||||||
return 1 + countRunBuff(buffs, "restoring-grace") * 0.15;
|
ranks: RunBuffRanks,
|
||||||
|
passiveInfusionId: RunBuffId | null = null,
|
||||||
|
random: () => number = Math.random,
|
||||||
|
count = 3,
|
||||||
|
): RunBuffId[] {
|
||||||
|
const pool = RUN_BUFF_ORDER.filter((id) => effectiveRunBuffRank(ranks, id, passiveInfusionId) < RUN_BUFFS[id].maxRank);
|
||||||
|
const choices: RunBuffId[] = [];
|
||||||
|
while (choices.length < count && pool.length > 0) {
|
||||||
|
const sample = random();
|
||||||
|
const randomValue = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999, sample)) : 0;
|
||||||
|
const index = Math.floor(randomValue * pool.length);
|
||||||
|
choices.push(pool[index]);
|
||||||
|
pool.splice(index, 1);
|
||||||
|
}
|
||||||
|
return choices;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compileRunModifiers(ranks: RunBuffRanks, passiveInfusionId: RunBuffId | null = null): CompiledRunModifiers {
|
||||||
|
const rank = (id: RunBuffId) => effectiveRunBuffRank(ranks, id, passiveInfusionId);
|
||||||
|
return {
|
||||||
|
mendExtraTargets: rank("mend-echo"),
|
||||||
|
mendManaMultiplier: 0.75 ** rank("mend-efficiency"),
|
||||||
|
mendCastTimeMultiplier: 0.75 ** rank("mend-cast-speed"),
|
||||||
|
renewExtraTargets: rank("renew-spread"),
|
||||||
|
renewDurationBonus: rank("renew-duration") * 2,
|
||||||
|
renewHealingMultiplier: 1 + rank("renew-potency") * 0.2,
|
||||||
|
shieldExtraTargets: rank("shield-echo"),
|
||||||
|
shieldAbsorbMultiplier: 1 + rank("shield-potency") * 0.25,
|
||||||
|
shieldDamageTakenMultiplier: 1 - rank("shield-guard") * 0.08,
|
||||||
|
purifyAppliesRenew: rank("purify-renew") > 0,
|
||||||
|
purifyAppliesShield: rank("purify-shield") > 0,
|
||||||
|
purifyExtraTargets: rank("purify-chain"),
|
||||||
|
radianceCooldownMultiplier: 0.8 ** rank("radiance-cooldown"),
|
||||||
|
radianceAppliesRenew: rank("radiance-renew") > 0,
|
||||||
|
radianceAbsorb: rank("radiance-shield") * 9,
|
||||||
|
barrierCooldownMultiplier: 0.8 ** rank("barrier-cooldown"),
|
||||||
|
barrierDurationBonus: rank("barrier-duration") * 2,
|
||||||
|
barrierHealingPerSecond: rank("barrier-regen") * 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runAbilityManaCost(abilityId: AbilityId, baseCost: number, modifiers: CompiledRunModifiers): number {
|
||||||
|
if (baseCost <= 0) return 0;
|
||||||
|
const multiplier = abilityId === "mend" ? modifiers.mendManaMultiplier : 1;
|
||||||
|
return Math.max(1, Math.ceil(baseCost * multiplier));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runAbilityCastTime(abilityId: AbilityId, baseCastTime: number, modifiers: CompiledRunModifiers): number {
|
||||||
|
return abilityId === "mend" ? baseCastTime * modifiers.mendCastTimeMultiplier : baseCastTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runAbilityCooldown(abilityId: AbilityId, baseCooldown: number, modifiers: CompiledRunModifiers): number {
|
||||||
|
if (abilityId === "radiance") return baseCooldown * modifiers.radianceCooldownMultiplier;
|
||||||
|
if (abilityId === "barrier") return baseCooldown * modifiers.barrierCooldownMultiplier;
|
||||||
|
return baseCooldown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRunBuffEffect(buffId: RunBuffId, requestedRank: number): string {
|
||||||
|
const rank = Math.max(1, Math.min(RUN_BUFFS[buffId].maxRank, requestedRank));
|
||||||
|
const reduced = (multiplier: number) => `${Math.round((1 - multiplier ** rank) * 100)}% less`;
|
||||||
|
switch (buffId) {
|
||||||
|
case "mend-echo": return `${rank} secondary ${rank === 1 ? "ally" : "allies"} at 50% healing`;
|
||||||
|
case "mend-efficiency": return `${reduced(0.75)} Mend mana cost`;
|
||||||
|
case "mend-cast-speed": return `${reduced(0.75)} Mend cast time`;
|
||||||
|
case "renew-spread": return `${rank} additional Renew ${rank === 1 ? "target" : "targets"}`;
|
||||||
|
case "renew-duration": return `+${rank * 2}s Renew duration`;
|
||||||
|
case "renew-potency": return `+${rank * 20}% Renew tick healing`;
|
||||||
|
case "shield-echo": return `${rank} secondary Shield ${rank === 1 ? "target" : "targets"} at 50% power`;
|
||||||
|
case "shield-potency": return `+${rank * 25}% healer absorption`;
|
||||||
|
case "shield-guard": return `${rank * 8}% less damage while shielded`;
|
||||||
|
case "purify-renew": return "Purify applies Renew";
|
||||||
|
case "purify-shield": return "Purify grants 50% Shield";
|
||||||
|
case "purify-chain": return "Purify cleanses one additional ally";
|
||||||
|
case "radiance-cooldown": return `${reduced(0.8)} Radiance cooldown`;
|
||||||
|
case "radiance-renew": return "Radiance applies Renew party-wide";
|
||||||
|
case "radiance-shield": return `+${rank * 9} base party absorption`;
|
||||||
|
case "barrier-cooldown": return `${reduced(0.8)} Barrier cooldown`;
|
||||||
|
case "barrier-duration": return `+${rank * 2}s Barrier duration`;
|
||||||
|
case "barrier-regen": return `${rank * 3} Barrier healing per second`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bossHealthMultiplier(round: number) {
|
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,
|
||||||
|
|||||||
+301
-38
@@ -1,11 +1,27 @@
|
|||||||
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 { dropVexaVenomPool, VEXA_VENOM } from "./bosses/vexa";
|
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
|
||||||
import { ARENA_CENTER, isInsideArena } from "./arena";
|
import { ARENA_CENTER, isInsideArena } from "./arena";
|
||||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||||
|
import { RUN_BUFF_ORDER, RUN_BUFFS, compileRunModifiers } from "./roguelike";
|
||||||
|
import type { RunBuffRanks } from "./types";
|
||||||
|
|
||||||
|
function startBuffedEncounter(runBuffRanks: RunBuffRanks) {
|
||||||
|
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
|
||||||
|
useGameStore.setState({ runBuffRanks, runModifiers: compileRunModifiers(runBuffRanks) });
|
||||||
|
useGameStore.getState().startEncounter();
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
boss: { ...state.boss, nextMeleeAt: 999 },
|
||||||
|
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function testDebuff(id: string) {
|
||||||
|
return { id, name: id, expiresAt: 10, nextTickAt: 9, tickDamage: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
describe("Disc Priest combat simulation", () => {
|
describe("Disc Priest combat simulation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -106,15 +122,16 @@ describe("Disc Priest combat simulation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("Purify removes Ember Brand from selected ally", () => {
|
it("Purify removes Ember Brand from selected ally", () => {
|
||||||
useGameStore.getState().tick(2);
|
useGameStore.setState((state) => ({
|
||||||
useGameStore.getState().tick(2);
|
bossMotion: { ...state.bossMotion, mechanicCount: 3, nextMechanicAt: state.time },
|
||||||
useGameStore.getState().tick(1.1);
|
}));
|
||||||
const branded = useGameStore.getState().party.find((member) => member.id === "nia")!;
|
useGameStore.getState().tick(0.1);
|
||||||
|
const branded = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Ember Brand"))!;
|
||||||
expect(branded.debuffs).toHaveLength(1);
|
expect(branded.debuffs).toHaveLength(1);
|
||||||
|
|
||||||
useGameStore.getState().selectMember("nia");
|
useGameStore.getState().selectMember(branded.id);
|
||||||
expect(useGameStore.getState().castAbility("purify")).toBe(true);
|
expect(useGameStore.getState().castAbility("purify")).toBe(true);
|
||||||
expect(useGameStore.getState().party.find((member) => member.id === "nia")?.debuffs).toHaveLength(0);
|
expect(useGameStore.getState().party.find((member) => member.id === branded.id)?.debuffs).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Radiance heals every living party member", () => {
|
it("Radiance heals every living party member", () => {
|
||||||
@@ -131,8 +148,7 @@ describe("Disc Priest combat simulation", () => {
|
|||||||
it("reduces damage by 30% for party members inside Barrier", () => {
|
it("reduces damage by 30% for party members inside Barrier", () => {
|
||||||
useGameStore.getState().setPlayerPosition([0, -3.7]);
|
useGameStore.getState().setPlayerPosition([0, -3.7]);
|
||||||
useGameStore.setState((state) => ({
|
useGameStore.setState((state) => ({
|
||||||
boss: { ...state.boss, nextNovaAt: 999, nextBrandAt: 999 },
|
boss: { ...state.boss },
|
||||||
bossMotion: { ...state.bossMotion, nextChargeAt: 999 },
|
|
||||||
}));
|
}));
|
||||||
expect(useGameStore.getState().castAbility("barrier")).toBe(true);
|
expect(useGameStore.getState().castAbility("barrier")).toBe(true);
|
||||||
useGameStore.getState().tick(2);
|
useGameStore.getState().tick(2);
|
||||||
@@ -190,40 +206,40 @@ describe("Disc Priest combat simulation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("telegraphs, executes, and recovers from a Bull charge", () => {
|
it("telegraphs, executes, and recovers from a Bull charge", () => {
|
||||||
while (useGameStore.getState().time < 7.1) useGameStore.getState().tick(0.1);
|
while (useGameStore.getState().bossMotion.mode !== "telegraph") useGameStore.getState().tick(0.1);
|
||||||
const telegraph = useGameStore.getState().bossMotion;
|
const telegraph = useGameStore.getState().bossMotion;
|
||||||
expect(telegraph.mode).toBe("telegraph");
|
expect(telegraph.mode).toBe("telegraph");
|
||||||
expect(telegraph.chargeTargetId).toBe("nia");
|
const targetId = telegraph.chargeTargetId;
|
||||||
|
|
||||||
const midpoint: [number, number] = [
|
const midpoint: [number, number] = [
|
||||||
(telegraph.chargeStart[0] + telegraph.chargeEnd[0]) / 2,
|
(telegraph.chargeStart[0] + telegraph.chargeEnd[0]) / 2,
|
||||||
(telegraph.chargeStart[1] + telegraph.chargeEnd[1]) / 2,
|
(telegraph.chargeStart[1] + telegraph.chargeEnd[1]) / 2,
|
||||||
];
|
];
|
||||||
useGameStore.setState((state) => ({
|
useGameStore.setState((state) => ({
|
||||||
partyPositions: { ...state.partyPositions, nia: midpoint },
|
partyPositions: { ...state.partyPositions, [targetId]: midpoint },
|
||||||
party: state.party.map((member) => member.id === "nia" ? { ...member, knockedUntil: state.time + 10 } : member),
|
party: state.party.map((member) => member.id === targetId ? { ...member, knockedUntil: state.time + 10 } : member),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
while (useGameStore.getState().bossMotion.mode === "telegraph") useGameStore.getState().tick(0.1);
|
while (useGameStore.getState().bossMotion.mode === "telegraph") useGameStore.getState().tick(0.1);
|
||||||
for (let step = 0; step < 30 && !useGameStore.getState().bossMotion.chargeHitIds.includes("nia"); step += 1) {
|
for (let step = 0; step < 30 && !useGameStore.getState().bossMotion.chargeHitIds.includes(targetId); step += 1) {
|
||||||
useGameStore.getState().tick(0.05);
|
useGameStore.getState().tick(0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hitState = useGameStore.getState();
|
const hitState = useGameStore.getState();
|
||||||
expect(hitState.bossMotion.chargeHitIds).toContain("nia");
|
expect(hitState.bossMotion.chargeHitIds).toContain(targetId);
|
||||||
expect(hitState.party.find((member) => member.id === "nia")!.knockedUntil - hitState.time).toBeCloseTo(0.75, 1);
|
expect(hitState.party.find((member) => member.id === targetId)!.knockedUntil - hitState.time).toBeCloseTo(0.75, 1);
|
||||||
|
|
||||||
for (let step = 0; step < 100 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) {
|
for (let step = 0; step < 100 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) {
|
||||||
useGameStore.getState().tick(0.1);
|
useGameStore.getState().tick(0.1);
|
||||||
}
|
}
|
||||||
const recovered = useGameStore.getState();
|
const recovered = useGameStore.getState();
|
||||||
expect(recovered.bossMotion.mode).toBe("holding");
|
expect(recovered.bossMotion.mode).toBe("holding");
|
||||||
expect(recovered.bossMotion.nextChargeAt).toBeGreaterThan(recovered.time);
|
expect(recovered.bossMotion.nextMechanicAt).toBeGreaterThan(recovered.time);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves every mobile AI party member out of the charge lane before impact", () => {
|
it("moves every mobile AI party member out of the charge lane before impact", () => {
|
||||||
useGameStore.setState((state) => ({
|
useGameStore.setState((state) => ({
|
||||||
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
|
boss: { ...state.boss, nextMeleeAt: 999 },
|
||||||
}));
|
}));
|
||||||
while (useGameStore.getState().bossMotion.mode !== "telegraph") {
|
while (useGameStore.getState().bossMotion.mode !== "telegraph") {
|
||||||
useGameStore.getState().tick(0.1);
|
useGameStore.getState().tick(0.1);
|
||||||
@@ -260,7 +276,7 @@ describe("Disc Priest combat simulation", () => {
|
|||||||
|
|
||||||
it("marks a stack target after three charges and splits 200 pounce damage", () => {
|
it("marks a stack target after three charges and splits 200 pounce damage", () => {
|
||||||
useGameStore.setState((state) => ({
|
useGameStore.setState((state) => ({
|
||||||
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
|
boss: { ...state.boss, nextMeleeAt: 999 },
|
||||||
playerPosition: [0, 0],
|
playerPosition: [0, 0],
|
||||||
partyPositions: {
|
partyPositions: {
|
||||||
aelia: [0, 0],
|
aelia: [0, 0],
|
||||||
@@ -269,18 +285,13 @@ describe("Disc Priest combat simulation", () => {
|
|||||||
orin: [0, 0],
|
orin: [0, 0],
|
||||||
vale: [0, 0],
|
vale: [0, 0],
|
||||||
},
|
},
|
||||||
bossMotion: {
|
bossMotion: { ...state.bossMotion, activeMechanicId: null, mode: "holding", position: [0, -1], mechanicCount: 1, nextMechanicAt: state.time },
|
||||||
...state.bossMotion,
|
|
||||||
mode: "returning",
|
|
||||||
position: [0, -1],
|
|
||||||
chargesSincePounce: 3,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
const startingHp = Object.fromEntries(useGameStore.getState().party.map((member) => [member.id, member.hp]));
|
const startingHp = Object.fromEntries(useGameStore.getState().party.map((member) => [member.id, member.hp]));
|
||||||
|
|
||||||
useGameStore.getState().tick(0.05);
|
useGameStore.getState().tick(0.05);
|
||||||
expect(useGameStore.getState().bossMotion.mode).toBe("stacking");
|
expect(useGameStore.getState().bossMotion.mode).toBe("stacking");
|
||||||
expect(useGameStore.getState().bossMotion.pounceTargetId).toBe("aelia");
|
expect(useGameStore.getState().bossMotion.pounceTargetId).toBeTruthy();
|
||||||
expect(useGameStore.getState().bossMotion.phaseEndsAt - useGameStore.getState().time).toBeCloseTo(5, 2);
|
expect(useGameStore.getState().bossMotion.phaseEndsAt - useGameStore.getState().time).toBeCloseTo(5, 2);
|
||||||
|
|
||||||
for (let step = 0; step < 70 && useGameStore.getState().bossMotion.mode === "stacking"; step += 1) {
|
for (let step = 0; step < 70 && useGameStore.getState().bossMotion.mode === "stacking"; step += 1) {
|
||||||
@@ -292,7 +303,7 @@ describe("Disc Priest combat simulation", () => {
|
|||||||
useGameStore.getState().tick(0.05);
|
useGameStore.getState().tick(0.05);
|
||||||
}
|
}
|
||||||
const impacted = useGameStore.getState();
|
const impacted = useGameStore.getState();
|
||||||
expect(impacted.bossMotion.mode).toBe("returning");
|
expect(impacted.bossMotion.mode).toBe("holding");
|
||||||
for (const member of impacted.party) {
|
for (const member of impacted.party) {
|
||||||
expect(member.hp).toBeCloseTo(startingHp[member.id] - 28, 3);
|
expect(member.hp).toBeCloseTo(startingHp[member.id] - 28, 3);
|
||||||
}
|
}
|
||||||
@@ -345,7 +356,7 @@ describe("Broodfang encounter", () => {
|
|||||||
useGameStore.setState((state) => ({
|
useGameStore.setState((state) => ({
|
||||||
boss: { ...state.boss, nextMeleeAt: 999 },
|
boss: { ...state.boss, nextMeleeAt: 999 },
|
||||||
bossMotion: {
|
bossMotion: {
|
||||||
...dropVexaVenomPool(state.bossMotion, "aelia", [0, 0], state.time),
|
...dropVenomPool(state.bossMotion, "aelia", [0, 0], state.time),
|
||||||
nextMechanicAt: 999,
|
nextMechanicAt: 999,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -356,8 +367,8 @@ describe("Broodfang encounter", () => {
|
|||||||
useGameStore.getState().tick(1);
|
useGameStore.getState().tick(1);
|
||||||
const secondTickHp = useGameStore.getState().party[0].hp;
|
const secondTickHp = useGameStore.getState().party[0].hp;
|
||||||
|
|
||||||
expect(firstTickHp).toBe(startingHp - VEXA_VENOM.poolDamage);
|
expect(firstTickHp).toBe(startingHp - VENOM_PURGE.poolDamage);
|
||||||
expect(secondTickHp).toBe(firstTickHp - VEXA_VENOM.poolDamage);
|
expect(secondTickHp).toBe(firstTickHp - VENOM_PURGE.poolDamage);
|
||||||
|
|
||||||
useGameStore.getState().setPlayerPosition([6, 6]);
|
useGameStore.getState().setPlayerPosition([6, 6]);
|
||||||
useGameStore.getState().tick(1);
|
useGameStore.getState().tick(1);
|
||||||
@@ -418,6 +429,129 @@ describe("PVE dual-boss encounter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Roguelike ability buffs", () => {
|
||||||
|
it("reduces Mend cost and cast time while echoing to lowest-health allies", () => {
|
||||||
|
startBuffedEncounter({ "mend-echo": 2, "mend-efficiency": 3, "mend-cast-speed": 3 });
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
party: state.party.map((member) => ({
|
||||||
|
...member,
|
||||||
|
hp: member.id === "aelia" ? 90 : member.id === "brann" ? 60 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : 30,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
useGameStore.getState().selectMember("brann");
|
||||||
|
|
||||||
|
expect(useGameStore.getState().castAbility("mend")).toBe(true);
|
||||||
|
expect(useGameStore.getState().mana).toBe(97);
|
||||||
|
expect(useGameStore.getState().activeCast?.completesAt).toBeCloseTo(0.5 * 0.75 ** 3);
|
||||||
|
useGameStore.getState().tick(0.22);
|
||||||
|
|
||||||
|
const party = useGameStore.getState().party;
|
||||||
|
expect(party.find((member) => member.id === "brann")?.hp).toBe(98);
|
||||||
|
expect(party.find((member) => member.id === "nia")?.hp).toBe(29);
|
||||||
|
expect(party.find((member) => member.id === "orin")?.hp).toBe(39);
|
||||||
|
expect(party.find((member) => member.id === "vale")?.hp).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spreads longer, stronger Renew effects without recursive targeting", () => {
|
||||||
|
startBuffedEncounter({ "renew-spread": 2, "renew-duration": 2, "renew-potency": 2 });
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
party: state.party.map((member) => ({
|
||||||
|
...member,
|
||||||
|
hp: member.id === "brann" ? 50 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : member.hp,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
useGameStore.getState().selectMember("brann");
|
||||||
|
expect(useGameStore.getState().castAbility("renew")).toBe(true);
|
||||||
|
|
||||||
|
let party = useGameStore.getState().party;
|
||||||
|
expect(party.filter((member) => member.renewExpiresAt === 12).map((member) => member.id)).toEqual(["brann", "nia", "orin"]);
|
||||||
|
useGameStore.getState().tick(1.01);
|
||||||
|
party = useGameStore.getState().party;
|
||||||
|
expect(party.find((member) => member.id === "brann")?.hp).toBeCloseTo(59.8);
|
||||||
|
expect(party.find((member) => member.id === "nia")?.hp).toBeCloseTo(19.8);
|
||||||
|
expect(party.find((member) => member.id === "orin")?.hp).toBeCloseTo(29.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strengthens and echoes Shield to deterministic secondary targets", () => {
|
||||||
|
startBuffedEncounter({ "shield-echo": 2, "shield-potency": 2 });
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
party: state.party.map((member) => ({
|
||||||
|
...member,
|
||||||
|
hp: member.id === "brann" ? 70 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : member.hp,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
useGameStore.getState().selectMember("brann");
|
||||||
|
expect(useGameStore.getState().castAbility("shield")).toBe(true);
|
||||||
|
|
||||||
|
const party = useGameStore.getState().party;
|
||||||
|
expect(party.find((member) => member.id === "brann")?.absorb).toBe(54);
|
||||||
|
expect(party.find((member) => member.id === "nia")?.absorb).toBe(27);
|
||||||
|
expect(party.find((member) => member.id === "orin")?.absorb).toBe(27);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chains Purify and applies triggered Renew and half-strength Shield", () => {
|
||||||
|
startBuffedEncounter({ "purify-renew": 1, "purify-shield": 1, "purify-chain": 1 });
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
party: state.party.map((member) => member.id === "brann"
|
||||||
|
? { ...member, hp: 70, debuffs: [testDebuff("tank-mark")] }
|
||||||
|
: member.id === "nia"
|
||||||
|
? { ...member, hp: 10, debuffs: [testDebuff("ranger-mark")] }
|
||||||
|
: member.id === "orin"
|
||||||
|
? { ...member, hp: 20, debuffs: [testDebuff("mage-mark")] }
|
||||||
|
: member),
|
||||||
|
}));
|
||||||
|
useGameStore.getState().selectMember("brann");
|
||||||
|
expect(useGameStore.getState().castAbility("purify")).toBe(true);
|
||||||
|
|
||||||
|
const party = useGameStore.getState().party;
|
||||||
|
for (const id of ["brann", "nia"] as const) {
|
||||||
|
const member = party.find((candidate) => candidate.id === id)!;
|
||||||
|
expect(member.debuffs).toEqual([]);
|
||||||
|
expect(member.renewExpiresAt).toBe(8);
|
||||||
|
expect(member.absorb).toBe(18);
|
||||||
|
}
|
||||||
|
expect(party.find((member) => member.id === "orin")?.debuffs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies Radiance cooldown, party Renew, and party absorption", () => {
|
||||||
|
startBuffedEncounter({ "radiance-cooldown": 3, "radiance-renew": 1, "radiance-shield": 2 });
|
||||||
|
useGameStore.setState((state) => ({ party: state.party.map((member) => ({ ...member, hp: Math.max(1, member.hp - 30) })) }));
|
||||||
|
expect(useGameStore.getState().castAbility("radiance")).toBe(true);
|
||||||
|
|
||||||
|
const state = useGameStore.getState();
|
||||||
|
expect(state.cooldowns.radiance).toBeCloseTo(14 * 0.8 ** 3);
|
||||||
|
expect(state.party.every((member) => member.renewExpiresAt === 8)).toBe(true);
|
||||||
|
expect(state.party.every((member) => member.absorb === 18)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extends, quickens, and pulses healing from Barrier", () => {
|
||||||
|
startBuffedEncounter({ "barrier-cooldown": 3, "barrier-duration": 3, "barrier-regen": 3 });
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 50 } : member),
|
||||||
|
}));
|
||||||
|
expect(useGameStore.getState().castAbility("barrier")).toBe(true);
|
||||||
|
expect(useGameStore.getState().cooldowns.barrier).toBeCloseTo(60 * 0.8 ** 3);
|
||||||
|
expect(useGameStore.getState().barrier.expiresAt).toBe(14);
|
||||||
|
useGameStore.getState().tick(1.01);
|
||||||
|
expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBe(59);
|
||||||
|
expect(useGameStore.getState().barrier.nextHealAt).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reduces incoming damage while absorption is present", () => {
|
||||||
|
startBuffedEncounter({ "shield-guard": 3 });
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
party: state.party.map((member) => member.id === "aelia" ? {
|
||||||
|
...member,
|
||||||
|
hp: 50,
|
||||||
|
absorb: 100,
|
||||||
|
debuffs: [{ id: "pulse", name: "Pulse", expiresAt: 2, nextTickAt: 0.5, tickDamage: 10 }],
|
||||||
|
} : member),
|
||||||
|
}));
|
||||||
|
useGameStore.getState().tick(1);
|
||||||
|
expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.absorb).toBeCloseTo(92.4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Roguelike rounds", () => {
|
describe("Roguelike rounds", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useGameStore.getState().configureHealer(
|
useGameStore.getState().configureHealer(
|
||||||
@@ -445,23 +579,151 @@ describe("Roguelike rounds", () => {
|
|||||||
expect(useGameStore.getState().phase).toBe("intermission");
|
expect(useGameStore.getState().phase).toBe("intermission");
|
||||||
expect(useGameStore.getState().round).toBe(1);
|
expect(useGameStore.getState().round).toBe(1);
|
||||||
expect(useGameStore.getState().time).toBe(intermissionTime);
|
expect(useGameStore.getState().time).toBe(intermissionTime);
|
||||||
expect(useGameStore.getState().chooseRunBuff("vital-bloom")).toBe(true);
|
const chosenBuffId = useGameStore.getState().draftBuffIds[0]!;
|
||||||
|
expect(chosenBuffId).toBeDefined();
|
||||||
|
useGameStore.setState({ runBuffInputUnlockAt: 0 });
|
||||||
|
expect(useGameStore.getState().chooseRunBuff(chosenBuffId)).toBe(true);
|
||||||
|
|
||||||
const roundTwo = useGameStore.getState();
|
const roundTwo = useGameStore.getState();
|
||||||
const nextBossIds = [roundTwo.boss.id, roundTwo.additionalBosses[0].boss.id];
|
const nextBossIds = [roundTwo.boss.id, roundTwo.additionalBosses[0].boss.id];
|
||||||
expect(roundTwo.phase).toBe("combat");
|
expect(roundTwo.phase).toBe("combat");
|
||||||
expect(roundTwo.round).toBe(2);
|
expect(roundTwo.round).toBe(2);
|
||||||
expect(roundTwo.runBuffs).toEqual(["vital-bloom"]);
|
expect(roundTwo.runBuffRanks[chosenBuffId]).toBe(1);
|
||||||
expect(nextBossIds.every((bossId) => !previousBossIds.includes(bossId))).toBe(true);
|
expect(nextBossIds.every((bossId) => !previousBossIds.includes(bossId))).toBe(true);
|
||||||
expect(roundTwo.boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.boss.id].maxHp * 1.1));
|
expect(roundTwo.boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.boss.id].maxHp * 1.1));
|
||||||
expect(roundTwo.additionalBosses[0].boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.additionalBosses[0].boss.id].maxHp * 1.1));
|
expect(roundTwo.additionalBosses[0].boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.additionalBosses[0].boss.id].maxHp * 1.1));
|
||||||
expect(roundTwo.party[0].maxHp).toBe(112);
|
expect(roundTwo.party[0].maxHp).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects buff claims outside intermission", () => {
|
it("rejects buff claims outside intermission", () => {
|
||||||
expect(useGameStore.getState().chooseRunBuff("deep-wells")).toBe(false);
|
expect(useGameStore.getState().chooseRunBuff("mend-efficiency")).toBe(false);
|
||||||
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", () => {
|
||||||
|
const maxedRanks = Object.fromEntries(RUN_BUFF_ORDER.map((id) => [id, RUN_BUFFS[id].maxRank])) as RunBuffRanks;
|
||||||
|
useGameStore.setState({
|
||||||
|
phase: "intermission",
|
||||||
|
runBuffRanks: maxedRanks,
|
||||||
|
runModifiers: compileRunModifiers(maxedRanks),
|
||||||
|
draftBuffIds: [],
|
||||||
|
selectedRunBuffId: null,
|
||||||
|
runBuffInputUnlockAt: Date.now() + RUN_BUFF_INPUT_LOCK_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useGameStore.getState().continueRoguelikeRound()).toBe(false);
|
||||||
|
useGameStore.setState({ runBuffInputUnlockAt: 0 });
|
||||||
|
expect(useGameStore.getState().continueRoguelikeRound()).toBe(true);
|
||||||
|
expect(useGameStore.getState().phase).toBe("combat");
|
||||||
|
expect(useGameStore.getState().round).toBe(2);
|
||||||
|
expect(useGameStore.getState().runBuffRanks).toEqual(maxedRanks);
|
||||||
|
expect(useGameStore.getState().draftBuffIds).toEqual([]);
|
||||||
|
expect(useGameStore.getState().continueRoguelikeRound()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rogue Trials", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useGameStore.getState().configureHealer(
|
||||||
|
"priest",
|
||||||
|
"Aelia",
|
||||||
|
createClassInventory("priest"),
|
||||||
|
["bulldrome", "broodfang-spider"],
|
||||||
|
"rogue-trials",
|
||||||
|
);
|
||||||
|
useGameStore.getState().startEncounter();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds toward an unseen three-boss finale on round five", () => {
|
||||||
|
for (let expectedRound = 2; expectedRound <= 5; expectedRound += 1) {
|
||||||
|
const before = useGameStore.getState();
|
||||||
|
const seenBefore = [...before.seenBossIds];
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
phase: "intermission",
|
||||||
|
runBuffInputUnlockAt: 0,
|
||||||
|
boss: { ...state.boss, hp: 0 },
|
||||||
|
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const buffId = useGameStore.getState().draftBuffIds[0]!;
|
||||||
|
expect(useGameStore.getState().chooseRunBuff(buffId)).toBe(true);
|
||||||
|
const next = useGameStore.getState();
|
||||||
|
const nextBossIds = [next.boss.id, ...next.additionalBosses.map((entry) => entry.boss.id)];
|
||||||
|
expect(next.round).toBe(expectedRound);
|
||||||
|
expect(nextBossIds).toHaveLength(expectedRound === 5 ? 3 : 2);
|
||||||
|
expect(nextBossIds.every((bossId) => !seenBefore.includes(bossId))).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finale = useGameStore.getState();
|
||||||
|
expect(finale.seenBossIds).toHaveLength(11);
|
||||||
|
expect(finale.additionalBosses).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends in victory after all three finale bosses fall", () => {
|
||||||
|
useGameStore.getState().configureHealer(
|
||||||
|
"priest",
|
||||||
|
"Aelia",
|
||||||
|
createClassInventory("priest"),
|
||||||
|
["ashwing-demon", "riftclaw-demon", "tempestscale-dragon"],
|
||||||
|
"rogue-trials",
|
||||||
|
);
|
||||||
|
useGameStore.getState().startEncounter();
|
||||||
|
useGameStore.setState((state) => ({
|
||||||
|
round: 5,
|
||||||
|
phase: "combat",
|
||||||
|
boss: { ...state.boss, hp: 0 },
|
||||||
|
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
useGameStore.getState().tick(0.05);
|
||||||
|
expect(useGameStore.getState().phase).toBe("victory");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts a completed trial with a fresh two-boss first round", () => {
|
||||||
|
useGameStore.getState().configureHealer(
|
||||||
|
"priest",
|
||||||
|
"Aelia",
|
||||||
|
createClassInventory("priest"),
|
||||||
|
["ashwing-demon", "riftclaw-demon", "tempestscale-dragon"],
|
||||||
|
"rogue-trials",
|
||||||
|
);
|
||||||
|
useGameStore.setState({ round: 5, phase: "victory" });
|
||||||
|
|
||||||
|
useGameStore.getState().restart();
|
||||||
|
const restarted = useGameStore.getState();
|
||||||
|
expect(restarted.round).toBe(1);
|
||||||
|
expect(restarted.phase).toBe("briefing");
|
||||||
|
expect(restarted.additionalBosses).toHaveLength(1);
|
||||||
|
expect(restarted.seenBossIds).toHaveLength(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shared arena boundary", () => {
|
describe("shared arena boundary", () => {
|
||||||
@@ -473,8 +735,8 @@ describe("shared arena boundary", () => {
|
|||||||
it("constrains player, party, and boss positions to the same room", () => {
|
it("constrains player, party, and boss positions to the same room", () => {
|
||||||
useGameStore.getState().setPlayerPosition([100, 100]);
|
useGameStore.getState().setPlayerPosition([100, 100]);
|
||||||
useGameStore.setState((state) => ({
|
useGameStore.setState((state) => ({
|
||||||
boss: { ...state.boss, nextMeleeAt: 999, nextNovaAt: 999, nextBrandAt: 999 },
|
boss: { ...state.boss, nextMeleeAt: 999 },
|
||||||
bossMotion: { ...state.bossMotion, position: [100, -100], nextChargeAt: 999 },
|
bossMotion: { ...state.bossMotion, position: [100, -100] },
|
||||||
partyPositions: {
|
partyPositions: {
|
||||||
...state.partyPositions,
|
...state.partyPositions,
|
||||||
brann: [50, 50],
|
brann: [50, 50],
|
||||||
@@ -533,6 +795,7 @@ describe("Tempestscale encounter", () => {
|
|||||||
partyPositions: { ...state.partyPositions, aelia: [0, 1] },
|
partyPositions: { ...state.partyPositions, aelia: [0, 1] },
|
||||||
bossMotion: {
|
bossMotion: {
|
||||||
...state.bossMotion,
|
...state.bossMotion,
|
||||||
|
activeMechanicId: "storm-breath",
|
||||||
mode: "breath_sweeping",
|
mode: "breath_sweeping",
|
||||||
position: [0, -2.8],
|
position: [0, -2.8],
|
||||||
phaseStartedAt: state.time,
|
phaseStartedAt: state.time,
|
||||||
|
|||||||
+203
-67
@@ -15,13 +15,18 @@ import { createClassInventory, HEALER_CLASSES } from "./healers";
|
|||||||
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
||||||
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
|
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
|
||||||
import {
|
import {
|
||||||
RUN_BUFF_ORDER,
|
|
||||||
RUN_BUFFS,
|
RUN_BUFFS,
|
||||||
applyRunBuffsToParty,
|
|
||||||
bossHealthMultiplier,
|
bossHealthMultiplier,
|
||||||
runHealingMultiplier,
|
compileRunModifiers,
|
||||||
runMaxMana,
|
increaseRunBuffRank,
|
||||||
|
runAbilityCastTime,
|
||||||
|
runAbilityCooldown,
|
||||||
|
runAbilityManaCost,
|
||||||
|
selectRunBuffDraft,
|
||||||
selectRandomBossPair,
|
selectRandomBossPair,
|
||||||
|
selectRogueTrialsBosses,
|
||||||
|
ROGUE_TRIALS_TRIO_ROUND,
|
||||||
|
type CompiledRunModifiers,
|
||||||
} from "./roguelike";
|
} from "./roguelike";
|
||||||
import { createDefaultGearProgress, type GearProgress } from "./progression/gear";
|
import { createDefaultGearProgress, type GearProgress } from "./progression/gear";
|
||||||
import { aiCombatModifiers, applyGearHealth, createEncounterGearModifiers, type EncounterGearModifiers } from "./progression/gearEffects";
|
import { aiCombatModifiers, applyGearHealth, createEncounterGearModifiers, type EncounterGearModifiers } from "./progression/gearEffects";
|
||||||
@@ -41,6 +46,7 @@ import type {
|
|||||||
MemberId,
|
MemberId,
|
||||||
PartyMember,
|
PartyMember,
|
||||||
RunBuffId,
|
RunBuffId,
|
||||||
|
RunBuffRanks,
|
||||||
RunMode,
|
RunMode,
|
||||||
ScenePulse,
|
ScenePulse,
|
||||||
WorldPosition,
|
WorldPosition,
|
||||||
@@ -68,9 +74,13 @@ export interface GameState {
|
|||||||
phase: GamePhase;
|
phase: GamePhase;
|
||||||
runMode: RunMode;
|
runMode: RunMode;
|
||||||
round: number;
|
round: number;
|
||||||
runBuffs: RunBuffId[];
|
seenBossIds: BossId[];
|
||||||
|
runBuffRanks: RunBuffRanks;
|
||||||
draftBuffIds: RunBuffId[];
|
draftBuffIds: RunBuffId[];
|
||||||
selectedRunBuffId: RunBuffId;
|
selectedRunBuffId: RunBuffId | null;
|
||||||
|
runBuffInputUnlockAt: number;
|
||||||
|
passiveRunBuffId: RunBuffId | null;
|
||||||
|
runModifiers: CompiledRunModifiers;
|
||||||
healingMultiplier: number;
|
healingMultiplier: number;
|
||||||
difficultySlug: DifficultySlug;
|
difficultySlug: DifficultySlug;
|
||||||
difficultyDamageMultiplier: number;
|
difficultyDamageMultiplier: number;
|
||||||
@@ -112,6 +122,7 @@ export interface GameState {
|
|||||||
setPauseSelection: (selection: "resume" | "exit") => void;
|
setPauseSelection: (selection: "resume" | "exit") => void;
|
||||||
setSelectedRunBuff: (buffId: RunBuffId) => void;
|
setSelectedRunBuff: (buffId: RunBuffId) => void;
|
||||||
chooseRunBuff: (buffId: RunBuffId) => boolean;
|
chooseRunBuff: (buffId: RunBuffId) => boolean;
|
||||||
|
continueRoguelikeRound: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyCooldowns = (): Record<AbilityId, number> => ({
|
const emptyCooldowns = (): Record<AbilityId, number> => ({
|
||||||
@@ -124,26 +135,26 @@ 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;
|
||||||
motion.chargeEnd[0] += offset;
|
motion.chargeEnd[0] += offset;
|
||||||
motion.pounceCenter[0] += offset;
|
motion.pounceCenter[0] += offset;
|
||||||
const stagger = index * 2.4;
|
const stagger = index * 2.4;
|
||||||
if (Number.isFinite(motion.nextChargeAt)) motion.nextChargeAt += stagger;
|
|
||||||
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger;
|
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger;
|
||||||
return constrainBossMotion(motion);
|
return constrainBossMotion(motion);
|
||||||
}
|
}
|
||||||
@@ -154,8 +165,6 @@ function createEncounterBoss(bossId: BossId, index: number, count: number, healt
|
|||||||
boss.hp = boss.maxHp;
|
boss.hp = boss.maxHp;
|
||||||
const stagger = index * 0.8;
|
const stagger = index * 0.8;
|
||||||
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += stagger;
|
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += stagger;
|
||||||
if (Number.isFinite(boss.nextNovaAt)) boss.nextNovaAt += index * 2.4;
|
|
||||||
if (Number.isFinite(boss.nextBrandAt)) boss.nextBrandAt += index * 2.4;
|
|
||||||
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count) };
|
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +199,45 @@ export function barrierProtects(position: WorldPosition, barrier: BarrierState,
|
|||||||
return barrier.expiresAt > time && distance(position, barrier.center) <= BARRIER_RADIUS;
|
return barrier.expiresAt > time && distance(position, barrier.center) <= BARRIER_RADIUS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lowestHealthIndexes(
|
||||||
|
party: readonly PartyMember[],
|
||||||
|
excludedIndex: number,
|
||||||
|
count: number,
|
||||||
|
predicate: (member: PartyMember) => boolean = () => true,
|
||||||
|
): number[] {
|
||||||
|
return party
|
||||||
|
.map((member, index) => ({ member, index }))
|
||||||
|
.filter(({ member, index }) => index !== excludedIndex && member.hp > 0 && predicate(member))
|
||||||
|
.sort((left, right) => (left.member.hp / left.member.maxHp) - (right.member.hp / right.member.maxHp) || left.index - right.index)
|
||||||
|
.slice(0, count)
|
||||||
|
.map(({ index }) => index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRenewAt(party: PartyMember[], index: number, time: number, modifiers: CompiledRunModifiers) {
|
||||||
|
if (party[index].hp <= 0) return;
|
||||||
|
party[index] = {
|
||||||
|
...party[index],
|
||||||
|
renewExpiresAt: time + 8 + modifiers.renewDurationBonus,
|
||||||
|
renewNextTickAt: time + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHealerAbsorb(
|
||||||
|
party: PartyMember[],
|
||||||
|
index: number,
|
||||||
|
baseAmount: number,
|
||||||
|
healingPower: number,
|
||||||
|
modifiers: CompiledRunModifiers,
|
||||||
|
) {
|
||||||
|
if (party[index].hp <= 0 || baseAmount <= 0) return 0;
|
||||||
|
const amount = baseAmount * healingPower * modifiers.shieldAbsorbMultiplier;
|
||||||
|
party[index] = {
|
||||||
|
...party[index],
|
||||||
|
absorb: Math.min(party[index].maxHp, party[index].absorb + amount),
|
||||||
|
};
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
function damageMemberAt(
|
function damageMemberAt(
|
||||||
member: PartyMember,
|
member: PartyMember,
|
||||||
amount: number,
|
amount: number,
|
||||||
@@ -201,6 +249,7 @@ function damageMemberAt(
|
|||||||
incomingDamageMultiplier = 1,
|
incomingDamageMultiplier = 1,
|
||||||
gearModifiers?: EncounterGearModifiers,
|
gearModifiers?: EncounterGearModifiers,
|
||||||
kind: "direct" | "hazard" = "direct",
|
kind: "direct" | "hazard" = "direct",
|
||||||
|
shieldDamageTakenMultiplier = 1,
|
||||||
) {
|
) {
|
||||||
amount *= incomingDamageMultiplier;
|
amount *= incomingDamageMultiplier;
|
||||||
if (kind === "hazard") amount *= gearModifiers?.[member.id].hazardDamageTaken ?? 1;
|
if (kind === "hazard") amount *= gearModifiers?.[member.id].hazardDamageTaken ?? 1;
|
||||||
@@ -211,7 +260,8 @@ function damageMemberAt(
|
|||||||
barrierProtects(position, barrier, time) ? BARRIER_DAMAGE_REDUCTION : 0,
|
barrierProtects(position, barrier, time) ? BARRIER_DAMAGE_REDUCTION : 0,
|
||||||
protectedByTank ? partyCombat?.tankAura.damageReduction ?? 0 : 0,
|
protectedByTank ? partyCombat?.tankAura.damageReduction ?? 0 : 0,
|
||||||
);
|
);
|
||||||
return damageMember(member, amount * (1 - reduction));
|
const shieldMultiplier = member.absorb > 0 ? shieldDamageTakenMultiplier : 1;
|
||||||
|
return damageMember(member, amount * (1 - reduction) * shieldMultiplier);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLog(
|
function addLog(
|
||||||
@@ -232,9 +282,10 @@ function initialState(
|
|||||||
requestedBossIds: BossId | readonly BossId[] = "bulldrome",
|
requestedBossIds: BossId | readonly BossId[] = "bulldrome",
|
||||||
runMode: RunMode = "encounter",
|
runMode: RunMode = "encounter",
|
||||||
round = 1,
|
round = 1,
|
||||||
runBuffs: RunBuffId[] = [],
|
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];
|
||||||
@@ -248,11 +299,10 @@ function initialState(
|
|||||||
const primary = encounterBosses[0];
|
const primary = encounterBosses[0];
|
||||||
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 effectiveRunBuffs = passiveInfusionId && !runBuffs.includes(passiveInfusionId)
|
const runModifiers = compileRunModifiers(runBuffRanks, passiveInfusionId);
|
||||||
? [passiveInfusionId, ...runBuffs]
|
const draftBuffIds = runMode !== "encounter" ? selectRunBuffDraft(runBuffRanks, passiveInfusionId) : [];
|
||||||
: runBuffs;
|
const party = applyGearHealth(freshParty(healerClassId, playerName), gearModifiers);
|
||||||
const party = applyGearHealth(applyRunBuffsToParty(freshParty(healerClassId, playerName), effectiveRunBuffs), gearModifiers);
|
const maxMana = 100;
|
||||||
const maxMana = runMaxMana(effectiveRunBuffs);
|
|
||||||
return {
|
return {
|
||||||
bossId: primary.boss.id,
|
bossId: primary.boss.id,
|
||||||
paused: false,
|
paused: false,
|
||||||
@@ -262,10 +312,14 @@ function initialState(
|
|||||||
phase: "briefing" as GamePhase,
|
phase: "briefing" as GamePhase,
|
||||||
runMode,
|
runMode,
|
||||||
round,
|
round,
|
||||||
runBuffs: [...runBuffs],
|
seenBossIds: [...new Set([...seenBossIds, ...bossIds])],
|
||||||
draftBuffIds: [...RUN_BUFF_ORDER],
|
runBuffRanks: { ...runBuffRanks },
|
||||||
selectedRunBuffId: RUN_BUFF_ORDER[0],
|
draftBuffIds,
|
||||||
healingMultiplier: runHealingMultiplier(effectiveRunBuffs) * gearModifiers.aelia.healingPower,
|
selectedRunBuffId: draftBuffIds[0] ?? null,
|
||||||
|
runBuffInputUnlockAt: 0,
|
||||||
|
passiveRunBuffId: passiveInfusionId,
|
||||||
|
runModifiers,
|
||||||
|
healingMultiplier: gearModifiers.aelia.healingPower,
|
||||||
difficultySlug,
|
difficultySlug,
|
||||||
difficultyDamageMultiplier: difficulty.damageMultiplier,
|
difficultyDamageMultiplier: difficulty.damageMultiplier,
|
||||||
gearProgress,
|
gearProgress,
|
||||||
@@ -290,7 +344,7 @@ function initialState(
|
|||||||
scenePulse: { id: 0, kind: "mend" as const },
|
scenePulse: { id: 0, kind: "mend" as const },
|
||||||
playerPosition: [0, 4.5] as [number, number],
|
playerPosition: [0, 4.5] as [number, number],
|
||||||
activeCast: null as ActiveCast | null,
|
activeCast: null as ActiveCast | null,
|
||||||
barrier: { center: [0, 4.5], expiresAt: 0 } as BarrierState,
|
barrier: { center: [0, 4.5], expiresAt: 0, nextHealAt: 0 } as BarrierState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,14 +352,14 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
...initialState(),
|
...initialState(),
|
||||||
|
|
||||||
configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate") => {
|
configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate") => {
|
||||||
set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, [], gearProgress, difficultySlug));
|
set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug));
|
||||||
},
|
},
|
||||||
|
|
||||||
startEncounter: () => {
|
startEncounter: () => {
|
||||||
const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, round, runBuffs, 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, runBuffs, 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" }],
|
||||||
@@ -314,7 +368,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 }),
|
||||||
@@ -334,25 +391,51 @@ 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 runBuffs = [...state.runBuffs, 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;
|
||||||
set({
|
set({
|
||||||
...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, "roguelike", round, runBuffs, 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: [{
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
time: 0,
|
time: 0,
|
||||||
message: `${RUN_BUFFS[buffId].name} claimed. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`,
|
message: `${abilityName}: ${RUN_BUFFS[buffId].name} claimed. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`,
|
||||||
|
tone: "good",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
continueRoguelikeRound: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.phase !== "intermission" || isRunBuffInputLocked(state) || state.draftBuffIds.length > 0) return false;
|
||||||
|
const round = state.round + 1;
|
||||||
|
const previousBossIds = [state.boss.id, ...state.additionalBosses.map((entry) => entry.boss.id)];
|
||||||
|
const bossIds = state.runMode === "rogue-trials"
|
||||||
|
? selectRogueTrialsBosses(round, state.seenBossIds)
|
||||||
|
: selectRandomBossPair(previousBossIds);
|
||||||
|
set({
|
||||||
|
...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, state.runMode, round, state.runBuffRanks, state.gearProgress, state.difficultySlug, state.seenBossIds),
|
||||||
|
phase: "combat",
|
||||||
|
activeTab: "combat",
|
||||||
|
combatLog: [{
|
||||||
|
id: Date.now(),
|
||||||
|
time: 0,
|
||||||
|
message: `All blessings mastered. Round ${round} begins at ${Math.round(bossHealthMultiplier(round) * 100)}% boss health.`,
|
||||||
tone: "good",
|
tone: "good",
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
@@ -381,12 +464,13 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
if (state.activeCast) return false;
|
if (state.activeCast) return false;
|
||||||
|
|
||||||
const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId];
|
const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId];
|
||||||
|
const manaCost = runAbilityManaCost(abilityId, ability.mana, state.runModifiers);
|
||||||
const selectedIndex = state.party.findIndex((member) => member.id === state.selectedMemberId);
|
const selectedIndex = state.party.findIndex((member) => member.id === state.selectedMemberId);
|
||||||
const selected = state.party[selectedIndex];
|
const selected = state.party[selectedIndex];
|
||||||
|
|
||||||
if (state.cooldowns[abilityId] > state.time + 0.01) return false;
|
if (state.cooldowns[abilityId] > state.time + 0.01) return false;
|
||||||
if (state.globalCooldownUntil > state.time + 0.001) return false;
|
if (state.globalCooldownUntil > state.time + 0.001) return false;
|
||||||
if (state.mana < ability.mana) {
|
if (state.mana < manaCost) {
|
||||||
set({ combatLog: addLog(state.combatLog, state.time, "Not enough mana.", "danger") });
|
set({ combatLog: addLog(state.combatLog, state.time, "Not enough mana.", "danger") });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -402,9 +486,9 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
abilityId: "mend",
|
abilityId: "mend",
|
||||||
targetId: selected.id,
|
targetId: selected.id,
|
||||||
startedAt: state.time,
|
startedAt: state.time,
|
||||||
completesAt: state.time + (ability.castTime ?? 0.5),
|
completesAt: state.time + runAbilityCastTime("mend", ability.castTime ?? 0.5, state.runModifiers),
|
||||||
},
|
},
|
||||||
mana: Math.max(0, state.mana - ability.mana),
|
mana: Math.max(0, state.mana - manaCost),
|
||||||
globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
|
globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS,
|
||||||
combatLog: addLog(state.combatLog, state.time, `Casting ${ability.name} on ${selected.name}...`),
|
combatLog: addLog(state.combatLog, state.time, `Casting ${ability.name} on ${selected.name}...`),
|
||||||
});
|
});
|
||||||
@@ -419,48 +503,73 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
|
|
||||||
switch (abilityId) {
|
switch (abilityId) {
|
||||||
case "renew":
|
case "renew":
|
||||||
party[selectedIndex] = {
|
applyRenewAt(party, selectedIndex, state.time, state.runModifiers);
|
||||||
...party[selectedIndex],
|
for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.renewExtraTargets)) {
|
||||||
renewExpiresAt: state.time + 8,
|
applyRenewAt(party, index, state.time, state.runModifiers);
|
||||||
renewNextTickAt: state.time + 1,
|
}
|
||||||
};
|
|
||||||
message = `${ability.name} placed on ${selected.name}.`;
|
message = `${ability.name} placed on ${selected.name}.`;
|
||||||
break;
|
break;
|
||||||
case "shield":
|
case "shield": {
|
||||||
party[selectedIndex] = {
|
const amount = addHealerAbsorb(party, selectedIndex, 36, state.gearModifiers.aelia.healingPower, state.runModifiers);
|
||||||
...party[selectedIndex],
|
for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.shieldExtraTargets)) {
|
||||||
absorb: Math.min(party[selectedIndex].maxHp, party[selectedIndex].absorb + 36 * state.gearModifiers.aelia.healingPower),
|
addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower, state.runModifiers);
|
||||||
};
|
}
|
||||||
message = `${selected.name} gains ${Math.round(36 * state.gearModifiers.aelia.healingPower)} absorption.`;
|
message = `${selected.name} gains ${Math.round(amount)} absorption.`;
|
||||||
break;
|
break;
|
||||||
case "purify":
|
}
|
||||||
{
|
case "purify": {
|
||||||
const dispelledNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
|
const cleanseIndexes = [
|
||||||
const primaryDispel = handleBossDispel(state.boss.id, state.bossMotion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
|
selectedIndex,
|
||||||
|
...lowestHealthIndexes(party, selectedIndex, state.runModifiers.purifyExtraTargets, (member) => member.debuffs.length > 0),
|
||||||
|
];
|
||||||
|
const primaryNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
|
||||||
|
for (const index of cleanseIndexes) {
|
||||||
|
const target = party[index];
|
||||||
|
const dispelledNames = target.debuffs.map((debuff) => debuff.name);
|
||||||
|
const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledNames);
|
||||||
bossMotion = primaryDispel.motion;
|
bossMotion = primaryDispel.motion;
|
||||||
additionalBosses = state.additionalBosses.map((entry) => {
|
additionalBosses = additionalBosses.map((entry) => {
|
||||||
const dispel = handleBossDispel(entry.boss.id, entry.motion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
|
const dispel = handleBossDispel(entry.boss.id, entry.motion, target.id, state.partyPositions[target.id], state.time, dispelledNames);
|
||||||
return { ...entry, motion: dispel.motion };
|
return { ...entry, motion: dispel.motion };
|
||||||
});
|
});
|
||||||
message = dispelledNames.includes("Widow Venom")
|
party[index] = { ...party[index], debuffs: [] };
|
||||||
? "Widow Venom purged. A venom pool forms where the target stood."
|
if (state.runModifiers.purifyAppliesRenew) applyRenewAt(party, index, state.time, state.runModifiers);
|
||||||
: `${dispelledNames.join(", ") || "Harmful magic"} removed from ${selected.name}.`;
|
if (state.runModifiers.purifyAppliesShield) {
|
||||||
|
addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower, state.runModifiers);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
party[selectedIndex] = { ...party[selectedIndex], debuffs: [] };
|
message = primaryNames.includes("Widow Venom")
|
||||||
|
? "Widow Venom purged. A venom pool forms where the target stood."
|
||||||
|
: `${primaryNames.join(", ") || "Harmful magic"} removed from ${selected.name}.`;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "radiance":
|
case "radiance":
|
||||||
party = party.map((member) => healMember(member, 22 * state.healingMultiplier));
|
party = party.map((member) => healMember(member, 22 * state.healingMultiplier));
|
||||||
|
if (state.runModifiers.radianceAppliesRenew) {
|
||||||
|
for (let index = 0; index < party.length; index += 1) applyRenewAt(party, index, state.time, state.runModifiers);
|
||||||
|
}
|
||||||
|
if (state.runModifiers.radianceAbsorb > 0) {
|
||||||
|
for (let index = 0; index < party.length; index += 1) {
|
||||||
|
addHealerAbsorb(party, index, state.runModifiers.radianceAbsorb, state.gearModifiers.aelia.healingPower, state.runModifiers);
|
||||||
|
}
|
||||||
|
}
|
||||||
message = `${ability.name} heals the full party.`;
|
message = `${ability.name} heals the full party.`;
|
||||||
break;
|
break;
|
||||||
case "barrier":
|
case "barrier":
|
||||||
barrier = { center: [...state.partyPositions.aelia], expiresAt: state.time + 8 };
|
barrier = {
|
||||||
message = `${ability.name} protects a 3m circle for 8 seconds.`;
|
center: [...state.partyPositions.aelia],
|
||||||
|
expiresAt: state.time + 8 + state.runModifiers.barrierDurationBonus,
|
||||||
|
nextHealAt: state.time + 1,
|
||||||
|
};
|
||||||
|
message = `${ability.name} protects a 3m circle for ${8 + state.runModifiers.barrierDurationBonus} seconds.`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cooldowns = {
|
const cooldowns = {
|
||||||
...state.cooldowns,
|
...state.cooldowns,
|
||||||
[abilityId]: ability.cooldown > 0 ? state.time + ability.cooldown * state.gearModifiers.aelia.cooldown : 0,
|
[abilityId]: ability.cooldown > 0
|
||||||
|
? state.time + runAbilityCooldown(abilityId, ability.cooldown, state.runModifiers) * state.gearModifiers.aelia.cooldown
|
||||||
|
: 0,
|
||||||
};
|
};
|
||||||
const pulse: ScenePulse = {
|
const pulse: ScenePulse = {
|
||||||
id: state.scenePulse.id + 1,
|
id: state.scenePulse.id + 1,
|
||||||
@@ -475,7 +584,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
barrier,
|
barrier,
|
||||||
bossMotion,
|
bossMotion,
|
||||||
additionalBosses,
|
additionalBosses,
|
||||||
mana: Math.max(0, state.mana - ability.mana),
|
mana: Math.max(0, state.mana - manaCost),
|
||||||
combatLog: addLog(state.combatLog, state.time, message, "good"),
|
combatLog: addLog(state.combatLog, state.time, message, "good"),
|
||||||
scenePulse: pulse,
|
scenePulse: pulse,
|
||||||
});
|
});
|
||||||
@@ -504,7 +613,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
let activeCast = state.activeCast ? { ...state.activeCast } : null;
|
let activeCast = state.activeCast ? { ...state.activeCast } : null;
|
||||||
let partyCombat = state.partyCombat;
|
let partyCombat = state.partyCombat;
|
||||||
let partyDamageEvents = state.partyDamageEvents;
|
let partyDamageEvents = state.partyDamageEvents;
|
||||||
const barrier = state.barrier;
|
let barrier = { ...state.barrier };
|
||||||
|
|
||||||
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);
|
||||||
@@ -512,6 +621,9 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
if (target?.hp > 0) {
|
if (target?.hp > 0) {
|
||||||
const healing = 38 * state.healingMultiplier;
|
const healing = 38 * state.healingMultiplier;
|
||||||
party[targetIndex] = healMember(target, healing);
|
party[targetIndex] = healMember(target, healing);
|
||||||
|
for (const index of lowestHealthIndexes(party, targetIndex, state.runModifiers.mendExtraTargets)) {
|
||||||
|
party[index] = healMember(party[index], healing * 0.5);
|
||||||
|
}
|
||||||
const abilityName = HEALER_CLASSES[state.healerClassId].abilities.mend.name;
|
const abilityName = HEALER_CLASSES[state.healerClassId].abilities.mend.name;
|
||||||
combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for ${Math.round(healing)}.`, "good");
|
combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for ${Math.round(healing)}.`, "good");
|
||||||
pulse = { id: pulse.id + 1, kind: "mend", targetId: target.id };
|
pulse = { id: pulse.id + 1, kind: "mend", targetId: target.id };
|
||||||
@@ -525,7 +637,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
let tickAt = next.renewNextTickAt;
|
let tickAt = next.renewNextTickAt;
|
||||||
const lastTickAt = Math.min(time, next.renewExpiresAt);
|
const lastTickAt = Math.min(time, next.renewExpiresAt);
|
||||||
while (tickAt <= lastTickAt + 0.001) {
|
while (tickAt <= lastTickAt + 0.001) {
|
||||||
next = healMember(next, 7 * state.healingMultiplier);
|
next = healMember(next, 7 * state.healingMultiplier * state.runModifiers.renewHealingMultiplier);
|
||||||
tickAt += 1;
|
tickAt += 1;
|
||||||
}
|
}
|
||||||
next = {
|
next = {
|
||||||
@@ -539,7 +651,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
.map((debuff) => {
|
.map((debuff) => {
|
||||||
let updated = { ...debuff };
|
let updated = { ...debuff };
|
||||||
while (updated.nextTickAt <= time && updated.nextTickAt < updated.expiresAt) {
|
while (updated.nextTickAt <= time && updated.nextTickAt < updated.expiresAt) {
|
||||||
next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers);
|
next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, "direct", state.runModifiers.shieldDamageTakenMultiplier);
|
||||||
updated.nextTickAt += 1;
|
updated.nextTickAt += 1;
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
@@ -548,6 +660,17 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
return { ...next, debuffs: activeDebuffs };
|
return { ...next, debuffs: activeDebuffs };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (state.runModifiers.barrierHealingPerSecond > 0) {
|
||||||
|
while (barrier.nextHealAt <= time && barrier.nextHealAt < barrier.expiresAt) {
|
||||||
|
const pulseAt = barrier.nextHealAt;
|
||||||
|
const healing = state.runModifiers.barrierHealingPerSecond * state.healingMultiplier;
|
||||||
|
party = party.map((member) => barrierProtects(state.partyPositions[member.id], barrier, pulseAt)
|
||||||
|
? healMember(member, healing)
|
||||||
|
: member);
|
||||||
|
barrier.nextHealAt += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const livingMotions = [
|
const livingMotions = [
|
||||||
...(boss.hp > 0 ? [bossMotion] : []),
|
...(boss.hp > 0 ? [bossMotion] : []),
|
||||||
...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion),
|
...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion),
|
||||||
@@ -574,8 +697,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
partyPositions,
|
partyPositions,
|
||||||
time,
|
time,
|
||||||
delta: time - oldTime,
|
delta: time - oldTime,
|
||||||
allowPooledMechanics: encounterBosses.length === 1,
|
damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind, state.runModifiers.shieldDamageTakenMultiplier),
|
||||||
damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind),
|
|
||||||
});
|
});
|
||||||
encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) };
|
encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) };
|
||||||
party = mechanicResult.party.map((member) => {
|
party = mechanicResult.party.map((member) => {
|
||||||
@@ -616,8 +738,11 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
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;
|
||||||
|
let runBuffInputUnlockAt = state.runBuffInputUnlockAt;
|
||||||
if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
|
if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
|
||||||
phase = state.runMode === "roguelike" ? "intermission" : "victory";
|
const rogueTrialsComplete = state.runMode === "rogue-trials" && state.round === ROGUE_TRIALS_TRIO_ROUND;
|
||||||
|
phase = state.runMode !== "encounter" && !rogueTrialsComplete ? "intermission" : "victory";
|
||||||
|
if (phase === "intermission") runBuffInputUnlockAt = Date.now() + RUN_BUFF_INPUT_LOCK_MS;
|
||||||
combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good");
|
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";
|
||||||
@@ -634,10 +759,12 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
partyPositions,
|
partyPositions,
|
||||||
bossMotion,
|
bossMotion,
|
||||||
phase,
|
phase,
|
||||||
|
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,
|
||||||
scenePulse: pulse,
|
scenePulse: pulse,
|
||||||
|
barrier,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -658,6 +785,7 @@ export type GameSnapshot = Omit<GameState,
|
|||||||
| "setPauseSelection"
|
| "setPauseSelection"
|
||||||
| "setSelectedRunBuff"
|
| "setSelectedRunBuff"
|
||||||
| "chooseRunBuff"
|
| "chooseRunBuff"
|
||||||
|
| "continueRoguelikeRound"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function getGameSnapshot(): GameSnapshot {
|
export function getGameSnapshot(): GameSnapshot {
|
||||||
@@ -677,6 +805,7 @@ export function getGameSnapshot(): GameSnapshot {
|
|||||||
setPauseSelection: _setPauseSelection,
|
setPauseSelection: _setPauseSelection,
|
||||||
setSelectedRunBuff: _setSelectedRunBuff,
|
setSelectedRunBuff: _setSelectedRunBuff,
|
||||||
chooseRunBuff: _chooseRunBuff,
|
chooseRunBuff: _chooseRunBuff,
|
||||||
|
continueRoguelikeRound: _continueRoguelikeRound,
|
||||||
...snapshot
|
...snapshot
|
||||||
} = useGameStore.getState();
|
} = useGameStore.getState();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
@@ -686,6 +815,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">) {
|
||||||
|
|||||||
+56
-34
@@ -28,16 +28,66 @@ export type BossId =
|
|||||||
| "moonfang-wolf"
|
| "moonfang-wolf"
|
||||||
| "frostmaw-yeti"
|
| "frostmaw-yeti"
|
||||||
| "rimeclaw-yeti";
|
| "rimeclaw-yeti";
|
||||||
|
export type BossMechanicId =
|
||||||
|
| "basic-melee"
|
||||||
|
| "bull-charge"
|
||||||
|
| "crushing-pounce"
|
||||||
|
| "cinder-nova"
|
||||||
|
| "ember-brand"
|
||||||
|
| "binding-web"
|
||||||
|
| "venom-purge"
|
||||||
|
| "storm-breath"
|
||||||
|
| "stormfall"
|
||||||
|
| "elemental-beam"
|
||||||
|
| "guardian-cross"
|
||||||
|
| "destruction-rush"
|
||||||
|
| "ruin-quake"
|
||||||
|
| "destruction-pulse"
|
||||||
|
| "ricochet-rush"
|
||||||
|
| "meteor-slam"
|
||||||
|
| "burrow-rush"
|
||||||
|
| "hourglass-eruption"
|
||||||
|
| "sidewinder-rush"
|
||||||
|
| "crushing-tide"
|
||||||
|
| "vine-scissors"
|
||||||
|
| "haunting-rifts"
|
||||||
|
| "tri-burst"
|
||||||
|
| "ultimate-skyfall"
|
||||||
|
| "meteor-spread"
|
||||||
|
| "hollow-collapse"
|
||||||
|
| "aetheric-soak"
|
||||||
|
| "prism-beam"
|
||||||
|
| "memory-sequence"
|
||||||
|
| "soul-siphon";
|
||||||
|
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 = "vital-bloom" | "deep-wells" | "restoring-grace";
|
export type RunBuffId =
|
||||||
|
| "mend-echo"
|
||||||
|
| "mend-efficiency"
|
||||||
|
| "mend-cast-speed"
|
||||||
|
| "renew-spread"
|
||||||
|
| "renew-duration"
|
||||||
|
| "renew-potency"
|
||||||
|
| "shield-echo"
|
||||||
|
| "shield-potency"
|
||||||
|
| "shield-guard"
|
||||||
|
| "purify-renew"
|
||||||
|
| "purify-shield"
|
||||||
|
| "purify-chain"
|
||||||
|
| "radiance-cooldown"
|
||||||
|
| "radiance-renew"
|
||||||
|
| "radiance-shield"
|
||||||
|
| "barrier-cooldown"
|
||||||
|
| "barrier-duration"
|
||||||
|
| "barrier-regen";
|
||||||
|
export type RunBuffRanks = Partial<Record<RunBuffId, number>>;
|
||||||
export type BottomTab = "combat" | "map" | "pack";
|
export type BottomTab = "combat" | "map" | "pack";
|
||||||
export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash";
|
export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash";
|
||||||
export type BossMotionMode =
|
export type BossMotionMode =
|
||||||
| "holding"
|
| "holding"
|
||||||
| "telegraph"
|
| "telegraph"
|
||||||
| "charging"
|
| "charging"
|
||||||
| "returning"
|
|
||||||
| "stacking"
|
| "stacking"
|
||||||
| "pouncing"
|
| "pouncing"
|
||||||
| "tethering"
|
| "tethering"
|
||||||
@@ -45,35 +95,12 @@ export type BossMotionMode =
|
|||||||
| "breath_telegraph"
|
| "breath_telegraph"
|
||||||
| "breath_sweeping"
|
| "breath_sweeping"
|
||||||
| "skyfall"
|
| "skyfall"
|
||||||
| "mantis_sidestep"
|
|
||||||
| "mantis_line_telegraph"
|
| "mantis_line_telegraph"
|
||||||
| "mantis_cross_telegraph"
|
| "mantis_cross_telegraph"
|
||||||
| "mantis_recover"
|
|
||||||
| "ram_charge_telegraph"
|
|
||||||
| "ram_charging"
|
|
||||||
| "ram_quake"
|
|
||||||
| "ram_shatter"
|
|
||||||
| "ram_recover"
|
|
||||||
| "cinderback_curl"
|
|
||||||
| "cinderback_ricochet"
|
|
||||||
| "cinderback_slam"
|
| "cinderback_slam"
|
||||||
| "cinderback_recover"
|
|
||||||
| "sandglass_burrow_telegraph"
|
|
||||||
| "sandglass_burrowing"
|
|
||||||
| "sandglass_eruption"
|
|
||||||
| "sandglass_hourglass"
|
| "sandglass_hourglass"
|
||||||
| "sandglass_recover"
|
|
||||||
| "crab_scuttle_telegraph"
|
|
||||||
| "crab_scuttling"
|
|
||||||
| "crab_tidal_burst"
|
|
||||||
| "crab_recover"
|
|
||||||
| "ghost_soul_cross"
|
|
||||||
| "ghost_soul_cross_followup"
|
|
||||||
| "ghost_haunting"
|
|
||||||
| "ghost_recover"
|
|
||||||
| "golem_shockwave"
|
| "golem_shockwave"
|
||||||
| "golem_crownfall"
|
| "golem_crownfall";
|
||||||
| "golem_recover";
|
|
||||||
export type WorldPosition = [number, number];
|
export type WorldPosition = [number, number];
|
||||||
|
|
||||||
export type CircleHazardKind =
|
export type CircleHazardKind =
|
||||||
@@ -194,13 +221,11 @@ export interface BossState {
|
|||||||
maxHp: number;
|
maxHp: number;
|
||||||
hp: number;
|
hp: number;
|
||||||
nextMeleeAt: number;
|
nextMeleeAt: number;
|
||||||
nextNovaAt: number;
|
|
||||||
nextBrandAt: number;
|
|
||||||
brandCount: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BossMotionState {
|
export interface BossMotionState {
|
||||||
bossId: BossId;
|
bossId: BossId;
|
||||||
|
activeMechanicId: BossMechanicId | null;
|
||||||
formationOffsetX: number;
|
formationOffsetX: number;
|
||||||
mode: BossMotionMode;
|
mode: BossMotionMode;
|
||||||
position: WorldPosition;
|
position: WorldPosition;
|
||||||
@@ -209,12 +234,9 @@ export interface BossMotionState {
|
|||||||
chargeTargetId: MemberId;
|
chargeTargetId: MemberId;
|
||||||
chargeHitIds: MemberId[];
|
chargeHitIds: MemberId[];
|
||||||
phaseEndsAt: number;
|
phaseEndsAt: number;
|
||||||
nextChargeAt: number;
|
|
||||||
chargeCount: number;
|
chargeCount: number;
|
||||||
chargesSincePounce: number;
|
|
||||||
pounceTargetId: MemberId;
|
pounceTargetId: MemberId;
|
||||||
pounceCenter: WorldPosition;
|
pounceCenter: WorldPosition;
|
||||||
pounceCount: number;
|
|
||||||
nextMechanicAt: number;
|
nextMechanicAt: number;
|
||||||
mechanicCount: number;
|
mechanicCount: number;
|
||||||
phaseStartedAt: number;
|
phaseStartedAt: number;
|
||||||
@@ -227,7 +249,6 @@ export interface BossMotionState {
|
|||||||
breathEndAngle: number;
|
breathEndAngle: number;
|
||||||
hazards: CircleHazard[];
|
hazards: CircleHazard[];
|
||||||
slashLanes: SlashLane[];
|
slashLanes: SlashLane[];
|
||||||
nextPoolMechanicAt: number;
|
|
||||||
poolMechanicCount: number;
|
poolMechanicCount: number;
|
||||||
poolTelegraphs: PoolTelegraph[];
|
poolTelegraphs: PoolTelegraph[];
|
||||||
}
|
}
|
||||||
@@ -257,6 +278,7 @@ export interface ActiveCast {
|
|||||||
export interface BarrierState {
|
export interface BarrierState {
|
||||||
center: WorldPosition;
|
center: WorldPosition;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
nextHealAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScenePulse {
|
export interface ScenePulse {
|
||||||
|
|||||||
+13
-4
@@ -1,12 +1,13 @@
|
|||||||
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 type { AbilityId } from "./types";
|
||||||
|
|
||||||
function cycleRunBuff(direction: 1 | -1) {
|
function cycleRunBuff(direction: 1 | -1) {
|
||||||
const store = useGameStore.getState();
|
const store = useGameStore.getState();
|
||||||
const currentIndex = store.draftBuffIds.indexOf(store.selectedRunBuffId);
|
if (store.draftBuffIds.length === 0) return;
|
||||||
|
const currentIndex = store.selectedRunBuffId ? store.draftBuffIds.indexOf(store.selectedRunBuffId) : -1;
|
||||||
const nextIndex = (Math.max(0, currentIndex) + direction + store.draftBuffIds.length) % store.draftBuffIds.length;
|
const nextIndex = (Math.max(0, currentIndex) + direction + store.draftBuffIds.length) % store.draftBuffIds.length;
|
||||||
store.setSelectedRunBuff(store.draftBuffIds[nextIndex]);
|
store.setSelectedRunBuff(store.draftBuffIds[nextIndex]);
|
||||||
}
|
}
|
||||||
@@ -42,9 +43,13 @@ 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") store.chooseRunBuff(store.selectedRunBuffId);
|
if (key === "enter") {
|
||||||
|
if (store.selectedRunBuffId) store.chooseRunBuff(store.selectedRunBuffId);
|
||||||
|
else store.continueRoguelikeRound();
|
||||||
|
}
|
||||||
if (key === "escape") exitRef.current?.();
|
if (key === "escape") exitRef.current?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -95,9 +100,13 @@ 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") store.chooseRunBuff(store.selectedRunBuffId);
|
if (!repeat && token === "Button0") {
|
||||||
|
if (store.selectedRunBuffId) store.chooseRunBuff(store.selectedRunBuffId);
|
||||||
|
else store.continueRoguelikeRound();
|
||||||
|
}
|
||||||
if (!repeat && token === "Button1") exitRef.current?.();
|
if (!repeat && token === "Button1") exitRef.current?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -77,11 +77,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 +94,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 +114,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 }),
|
||||||
@@ -122,6 +129,8 @@ export function BottomDisplayApp() {
|
|||||||
selectGearSlot: (slotId) => postFrontend({ name: "selectGearSlot", slotId }),
|
selectGearSlot: (slotId) => postFrontend({ name: "selectGearSlot", slotId }),
|
||||||
selectGearWorkshopMode: (mode) => postFrontend({ name: "selectGearWorkshopMode", mode }),
|
selectGearWorkshopMode: (mode) => postFrontend({ name: "selectGearWorkshopMode", mode }),
|
||||||
selectInfusion: (infusionId) => postFrontend({ name: "selectInfusion", infusionId }),
|
selectInfusion: (infusionId) => postFrontend({ name: "selectInfusion", infusionId }),
|
||||||
|
selectPassiveAbility: (abilityId) => postFrontend({ name: "selectPassiveAbility", abilityId }),
|
||||||
|
selectPassiveInfusion: (passiveId) => postFrontend({ name: "selectPassiveInfusion", passiveId }),
|
||||||
upgradeSelectedGear: () => {
|
upgradeSelectedGear: () => {
|
||||||
postFrontend({ name: "upgradeSelectedGear" });
|
postFrontend({ name: "upgradeSelectedGear" });
|
||||||
return false;
|
return false;
|
||||||
@@ -155,6 +164,10 @@ export function BottomDisplayApp() {
|
|||||||
postCommand({ name: "chooseRunBuff", buffId });
|
postCommand({ name: "chooseRunBuff", buffId });
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
continueRoguelikeRound: () => {
|
||||||
|
postCommand({ name: "continueRoguelikeRound" });
|
||||||
|
return false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||||
if (event.data.type === "authoritative-ready") {
|
if (event.data.type === "authoritative-ready") {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { diffBottomGameSnapshot, type BottomGameSnapshot } from "./dualScreenSync";
|
import { diffBottomGameSnapshot, executeFrontendCommand, executeGameCommand, type BottomGameSnapshot } from "./dualScreenSync";
|
||||||
|
import { useGameStore } from "../game/store";
|
||||||
|
import { useFrontendStore } from "../frontend/store";
|
||||||
|
|
||||||
function snapshot(): BottomGameSnapshot {
|
function snapshot(): BottomGameSnapshot {
|
||||||
return {
|
return {
|
||||||
@@ -8,16 +10,24 @@ function snapshot(): BottomGameSnapshot {
|
|||||||
healerClassId: "priest",
|
healerClassId: "priest",
|
||||||
phase: "combat",
|
phase: "combat",
|
||||||
round: 1,
|
round: 1,
|
||||||
|
runModifiers: {
|
||||||
|
mendExtraTargets: 0, mendManaMultiplier: 1, mendCastTimeMultiplier: 1,
|
||||||
|
renewExtraTargets: 0, renewDurationBonus: 0, renewHealingMultiplier: 1,
|
||||||
|
shieldExtraTargets: 0, shieldAbsorbMultiplier: 1, shieldDamageTakenMultiplier: 1,
|
||||||
|
purifyAppliesRenew: false, purifyAppliesShield: false, purifyExtraTargets: 0,
|
||||||
|
radianceCooldownMultiplier: 1, radianceAppliesRenew: false, radianceAbsorb: 0,
|
||||||
|
barrierCooldownMultiplier: 1, barrierDurationBonus: 0, barrierHealingPerSecond: 0,
|
||||||
|
},
|
||||||
time: 10,
|
time: 10,
|
||||||
party: [],
|
party: [],
|
||||||
boss: { id: "bulldrome", name: "Bulldrome", maxHp: 100, hp: 100, nextMeleeAt: 1, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 },
|
boss: { id: "bulldrome", name: "Bulldrome", maxHp: 100, hp: 100, nextMeleeAt: 1 },
|
||||||
additionalBosses: [],
|
additionalBosses: [],
|
||||||
partyPositions: { aelia: [0, 0], brann: [0, 0], nia: [0, 0], orin: [0, 0], vale: [0, 0] },
|
partyPositions: { aelia: [0, 0], brann: [0, 0], nia: [0, 0], orin: [0, 0], vale: [0, 0] },
|
||||||
bossMotion: {
|
bossMotion: {
|
||||||
bossId: "bulldrome", formationOffsetX: 0, mode: "holding", position: [0, 0], chargeStart: [0, 0], chargeEnd: [0, 0], chargeTargetId: "aelia", chargeHitIds: [], phaseEndsAt: 0,
|
bossId: "bulldrome", activeMechanicId: null, formationOffsetX: 0, mode: "holding", position: [0, 0], chargeStart: [0, 0], chargeEnd: [0, 0], chargeTargetId: "aelia", chargeHitIds: [], phaseEndsAt: 0,
|
||||||
nextChargeAt: Infinity, chargeCount: 0, chargesSincePounce: 0, pounceTargetId: "aelia", pounceCenter: [0, 0], pounceCount: 0,
|
chargeCount: 0, pounceTargetId: "aelia", pounceCenter: [0, 0],
|
||||||
nextMechanicAt: Infinity, mechanicCount: 0, phaseStartedAt: 0, mechanicHitIds: [], mechanicNextDamageAt: {}, tetherIds: [], tetherBreakDistance: 0,
|
nextMechanicAt: Infinity, mechanicCount: 0, phaseStartedAt: 0, mechanicHitIds: [], mechanicNextDamageAt: {}, tetherIds: [], tetherBreakDistance: 0,
|
||||||
breathAngle: 0, breathStartAngle: 0, breathEndAngle: 0, hazards: [], slashLanes: [], nextPoolMechanicAt: Infinity, poolMechanicCount: 0, poolTelegraphs: [],
|
breathAngle: 0, breathStartAngle: 0, breathEndAngle: 0, hazards: [], slashLanes: [], poolMechanicCount: 0, poolTelegraphs: [],
|
||||||
},
|
},
|
||||||
partyCombat: {
|
partyCombat: {
|
||||||
combatants: {
|
combatants: {
|
||||||
@@ -39,7 +49,7 @@ function snapshot(): BottomGameSnapshot {
|
|||||||
inventory: [],
|
inventory: [],
|
||||||
playerPosition: [0, 0],
|
playerPosition: [0, 0],
|
||||||
activeCast: null,
|
activeCast: null,
|
||||||
barrier: { center: [0, 0], expiresAt: 0 },
|
barrier: { center: [0, 0], expiresAt: 0, nextHealAt: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,4 +64,24 @@ describe("dual-screen game snapshots", () => {
|
|||||||
const next = { ...clonedWithoutChanges, time: 10.1, mana: 97 };
|
const next = { ...clonedWithoutChanges, time: 10.1, mana: 97 };
|
||||||
expect(diffBottomGameSnapshot(clonedWithoutChanges, next)).toEqual({ time: 10.1, mana: 97 });
|
expect(diffBottomGameSnapshot(clonedWithoutChanges, next)).toEqual({ time: 10.1, mana: 97 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes maxed-run continuation and passive filter commands", () => {
|
||||||
|
const originalContinue = useGameStore.getState().continueRoguelikeRound;
|
||||||
|
const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility;
|
||||||
|
const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion;
|
||||||
|
const calls: string[] = [];
|
||||||
|
useGameStore.setState({ continueRoguelikeRound: () => { calls.push("continue"); return true; } });
|
||||||
|
useFrontendStore.setState({
|
||||||
|
selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); },
|
||||||
|
selectPassiveInfusion: (passiveId) => { calls.push(`passive:${passiveId}`); },
|
||||||
|
});
|
||||||
|
|
||||||
|
executeGameCommand({ name: "continueRoguelikeRound" });
|
||||||
|
executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "shield" });
|
||||||
|
executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" });
|
||||||
|
expect(calls).toEqual(["continue", "ability:shield", "passive:shield-guard"]);
|
||||||
|
|
||||||
|
useGameStore.setState({ continueRoguelikeRound: originalContinue });
|
||||||
|
useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, selectPassiveInfusion: originalSelectPassive });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export type GameCommand =
|
|||||||
| { name: "setPaused"; paused: boolean }
|
| { name: "setPaused"; paused: boolean }
|
||||||
| { 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" };
|
||||||
|
|
||||||
export type FrontendCommand =
|
export type FrontendCommand =
|
||||||
| { name: "signIn"; username: string; password: string }
|
| { name: "signIn"; username: string; password: string }
|
||||||
@@ -44,6 +45,8 @@ export type FrontendCommand =
|
|||||||
| { name: "selectGearSlot"; slotId: GearSlotId }
|
| { name: "selectGearSlot"; slotId: GearSlotId }
|
||||||
| { name: "selectGearWorkshopMode"; mode: "upgrade" | "infusion" }
|
| { name: "selectGearWorkshopMode"; mode: "upgrade" | "infusion" }
|
||||||
| { name: "selectInfusion"; infusionId: string }
|
| { name: "selectInfusion"; infusionId: string }
|
||||||
|
| { name: "selectPassiveAbility"; abilityId: AbilityId }
|
||||||
|
| { name: "selectPassiveInfusion"; passiveId: RunBuffId }
|
||||||
| { name: "upgradeSelectedGear" }
|
| { name: "upgradeSelectedGear" }
|
||||||
| { name: "equipSelectedInfusion" }
|
| { name: "equipSelectedInfusion" }
|
||||||
| { name: "equipPassiveInfusion"; passiveId: RunBuffId }
|
| { name: "equipPassiveInfusion"; passiveId: RunBuffId }
|
||||||
@@ -82,6 +85,7 @@ export function executeGameCommand(command: GameCommand) {
|
|||||||
case "setPauseSelection": game.setPauseSelection(command.selection); break;
|
case "setPauseSelection": game.setPauseSelection(command.selection); break;
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +111,8 @@ export function executeFrontendCommand(command: FrontendCommand) {
|
|||||||
case "selectGearSlot": frontend.selectGearSlot(command.slotId); break;
|
case "selectGearSlot": frontend.selectGearSlot(command.slotId); break;
|
||||||
case "selectGearWorkshopMode": frontend.selectGearWorkshopMode(command.mode); break;
|
case "selectGearWorkshopMode": frontend.selectGearWorkshopMode(command.mode); break;
|
||||||
case "selectInfusion": frontend.selectInfusion(command.infusionId); break;
|
case "selectInfusion": frontend.selectInfusion(command.infusionId); break;
|
||||||
|
case "selectPassiveAbility": frontend.selectPassiveAbility(command.abilityId); break;
|
||||||
|
case "selectPassiveInfusion": frontend.selectPassiveInfusion(command.passiveId); break;
|
||||||
case "upgradeSelectedGear": frontend.upgradeSelectedGear(); break;
|
case "upgradeSelectedGear": frontend.upgradeSelectedGear(); break;
|
||||||
case "equipSelectedInfusion": frontend.equipSelectedInfusion(); break;
|
case "equipSelectedInfusion": frontend.equipSelectedInfusion(); break;
|
||||||
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
|
case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break;
|
||||||
@@ -130,6 +136,7 @@ export type BottomGameSnapshot = Pick<GameState,
|
|||||||
| "healerClassId"
|
| "healerClassId"
|
||||||
| "phase"
|
| "phase"
|
||||||
| "round"
|
| "round"
|
||||||
|
| "runModifiers"
|
||||||
| "time"
|
| "time"
|
||||||
| "party"
|
| "party"
|
||||||
| "boss"
|
| "boss"
|
||||||
@@ -151,7 +158,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", "time", "party", "boss", "additionalBosses",
|
"bossId", "paused", "healerClassId", "phase", "round", "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",
|
||||||
];
|
];
|
||||||
@@ -181,6 +188,7 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot {
|
|||||||
healerClassId: state.healerClassId,
|
healerClassId: state.healerClassId,
|
||||||
phase: state.phase,
|
phase: state.phase,
|
||||||
round: state.round,
|
round: state.round,
|
||||||
|
runModifiers: state.runModifiers,
|
||||||
time: state.time,
|
time: state.time,
|
||||||
party: state.party,
|
party: state.party,
|
||||||
boss: state.boss,
|
boss: state.boss,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+218
-44
@@ -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;
|
||||||
@@ -1117,6 +1120,8 @@ button:focus-visible {
|
|||||||
.buff-draft > header h2 { margin: 2px 0; font-family: "Cinzel", serif; font-size: clamp(18px, 4.2cqw, 26px); font-weight: 500; }
|
.buff-draft > header h2 { margin: 2px 0; font-family: "Cinzel", serif; font-size: clamp(18px, 4.2cqw, 26px); font-weight: 500; }
|
||||||
.buff-draft > header p { margin: 0; color: #81958d; font-size: clamp(8px, 1.7cqw, 10px); }
|
.buff-draft > header p { margin: 0; color: #81958d; font-size: clamp(8px, 1.7cqw, 10px); }
|
||||||
.buff-choice-grid { min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2.5%; }
|
.buff-choice-grid { min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2.5%; }
|
||||||
|
.buff-choice-grid.choice-count-1 { grid-template-columns: minmax(0, 360px); justify-content: center; }
|
||||||
|
.buff-choice-grid.choice-count-2 { width: min(640px, 100%); grid-template-columns: repeat(2, minmax(0, 1fr)); justify-self: center; }
|
||||||
.buff-choice-grid button {
|
.buff-choice-grid button {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1132,12 +1137,15 @@ 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; }
|
||||||
.buff-choice-grid button strong { overflow: hidden; font-family: "Cinzel", serif; font-size: clamp(9px, 2cqw, 12px); text-overflow: ellipsis; white-space: nowrap; }
|
.buff-choice-grid button strong { overflow: hidden; font-family: "Cinzel", serif; font-size: clamp(9px, 2cqw, 12px); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.buff-choice-grid button > b { grid-column: 1 / -1; color: #edf5f1; font-size: clamp(8px, 1.7cqw, 10px); }
|
.buff-choice-grid button > b { grid-column: 1 / -1; color: #edf5f1; font-size: clamp(8px, 1.7cqw, 10px); }
|
||||||
.buff-choice-grid button > p { grid-column: 1 / -1; margin: 0; color: #71867e; font-size: clamp(7px, 1.45cqw, 9px); line-height: 1.25; }
|
.buff-choice-grid button > p { grid-column: 1 / -1; margin: 0; color: #71867e; font-size: clamp(7px, 1.45cqw, 9px); line-height: 1.25; }
|
||||||
|
.buff-choice-grid .buff-mastery-continue { grid-column: 1 / -1; width: min(420px, 100%); justify-self: center; align-self: center; }
|
||||||
.buff-draft > footer { display: flex; align-items: center; justify-content: center; gap: 7px; color: #6e827a; font-size: 7px; letter-spacing: 0.08em; text-transform: uppercase; }
|
.buff-draft > footer { display: flex; align-items: center; justify-content: center; gap: 7px; color: #6e827a; font-size: 7px; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||||
.buff-draft > footer b { color: #dce8e3; }
|
.buff-draft > footer b { color: #dce8e3; }
|
||||||
.buff-draft > footer i { width: 2px; height: 2px; border-radius: 50%; background: var(--gold); }
|
.buff-draft > footer i { width: 2px; height: 2px; border-radius: 50%; background: var(--gold); }
|
||||||
@@ -1275,9 +1283,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); }
|
||||||
@@ -1321,7 +1329,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%;
|
||||||
@@ -1329,11 +1337,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,7 +1758,7 @@ button:focus-visible {
|
|||||||
.home-title { padding: 17px 2px 12px; }
|
.home-title { padding: 17px 2px 12px; }
|
||||||
.home-title span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; }
|
.home-title 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; }
|
.home-title h1 { margin: 2px 0 0; font-family: "Cinzel", serif; font-size: 25px; font-weight: 500; }
|
||||||
.mode-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, 84px); gap: 10px; }
|
.mode-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, 78px); gap: 10px; }
|
||||||
.mode-card { position: relative; display: grid; grid-template-columns: 54px 1fr 17px; align-items: center; gap: 12px; padding: 13px; overflow: hidden; text-align: left; }
|
.mode-card { 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; }
|
||||||
@@ -1808,6 +1816,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; }
|
||||||
@@ -1829,18 +1841,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: 52px; display: grid; grid-template-columns: 30px minmax(0, 1fr) 32px; align-items: center; gap: 8px; padding: 6px 9px; text-align: left; }
|
||||||
|
.boss-stat-selector button.is-selected { border-color: var(--gold); box-shadow: inset 3px 0 var(--gold); background: linear-gradient(90deg, rgba(93,73,23,.27), rgba(8,18,15,.88)); }
|
||||||
|
.boss-stat-selector button > i { width: 27px; height: 27px; display: grid; place-items: center; border: 1px solid #496159; color: var(--gold); font-style: normal; }
|
||||||
|
.boss-stat-selector button > span { min-width: 0; display: grid; }
|
||||||
|
.boss-stat-selector strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.boss-stat-selector small { overflow: hidden; color: #6f837b; font-size: 7px; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; }
|
||||||
|
.boss-stat-selector button > b { color: var(--gold-strong); font-family: "Cinzel", serif; font-size: 16px; text-align: right; }
|
||||||
|
.leaderboard-panel { min-width: 0; overflow: hidden; border: 1px solid #435950; background: linear-gradient(180deg, rgba(11,25,21,.94), rgba(5,13,11,.94)); }
|
||||||
|
.leaderboard-panel > header { height: 53px; display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--line); background: rgba(59,47,17,.16); }
|
||||||
|
.leaderboard-panel > header span { min-width: 0; display: grid; }
|
||||||
|
.leaderboard-panel > header small { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
|
||||||
|
.leaderboard-panel > header strong { overflow: hidden; font: 500 13px "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.leaderboard-panel > header > b { color: #88a79b; font-size: 8px; text-transform: uppercase; }
|
||||||
|
.leaderboard-rows { min-height: 218px; padding: 4px 8px; }
|
||||||
|
.leaderboard-rows > div, .leaderboard-self { min-height: 40px; display: grid; grid-template-columns: 34px minmax(0, 1fr) 44px; align-items: center; gap: 8px; padding: 4px 7px; border-bottom: 1px solid rgba(164,195,184,.1); }
|
||||||
|
.leaderboard-rows > div.is-you { background: rgba(102,81,24,.2); }
|
||||||
|
.leaderboard-rows > div > b, .leaderboard-self > b { color: var(--gold); font: 600 12px "Cinzel", serif; }
|
||||||
|
.leaderboard-rows span, .leaderboard-self span { min-width: 0; display: grid; }
|
||||||
|
.leaderboard-rows strong, .leaderboard-self strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.leaderboard-rows small, .leaderboard-self small { color: #6d8179; font-size: 6px; text-transform: uppercase; }
|
||||||
|
.leaderboard-rows em, .leaderboard-self em { color: #e8f3ee; font: 500 15px "Cinzel", serif; font-style: normal; text-align: right; }
|
||||||
|
.leaderboard-self { min-height: 48px; margin: 0 8px 7px; border: 1px solid rgba(232,200,114,.33); background: rgba(79,62,19,.2); }
|
||||||
|
.leaderboard-status, .leaderboard-empty { min-height: 218px; display: grid; place-items: center; padding: 20px; color: #71867e; font-size: 9px; text-align: center; text-transform: uppercase; }
|
||||||
|
.leaderboard-empty { min-height: 200px !important; border: 0 !important; }
|
||||||
.profile-context { padding: 0 5.5% 18px; }
|
.profile-context { 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(5, 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; }
|
||||||
@@ -1908,7 +1972,18 @@ button:focus-visible {
|
|||||||
.boss-picker-heading > div { display: flex; gap: 4px; }
|
.boss-picker-heading > div { display: flex; gap: 4px; }
|
||||||
.boss-picker-heading button { min-width: 72px; padding: 2px 6px; border: 1px solid var(--line); color: #a9bbb4; background: rgba(6,18,16,0.82); font-size: 7px; text-transform: uppercase; }
|
.boss-picker-heading button { min-width: 72px; padding: 2px 6px; border: 1px solid var(--line); color: #a9bbb4; background: rgba(6,18,16,0.82); font-size: 7px; text-transform: uppercase; }
|
||||||
.boss-picker-heading button:disabled { opacity: 0.32; }
|
.boss-picker-heading button:disabled { opacity: 0.32; }
|
||||||
.boss-choice-grid { display: grid; grid-template-columns: repeat(var(--boss-grid-columns), minmax(0, 1fr)); grid-template-rows: repeat(var(--boss-grid-rows), minmax(40px, auto)); grid-auto-flow: column; gap: 4px; }
|
.boss-group-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 4px; }
|
||||||
|
.boss-group-choice { min-height: 30px; display: grid; grid-template-columns: 20px minmax(0, 1fr); align-items: center; gap: 5px; padding: 4px 6px; border: 1px solid var(--line); color: #9db2aa; background: rgba(6,18,16,0.82); text-align: left; }
|
||||||
|
.boss-group-choice > b { display: grid; width: 19px; height: 19px; place-items: center; border: 1px solid #527066; border-radius: 50%; color: var(--gold); font-family: "Cinzel", serif; font-size: 9px; }
|
||||||
|
.boss-group-choice > span { display: grid; min-width: 0; }
|
||||||
|
.boss-group-choice strong { font-size: 7px; }
|
||||||
|
.boss-group-choice small { overflow: hidden; color: #6f867d; font-size: 6px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.boss-group-choice.is-selected { border-color: var(--gold); background: rgba(87,69,25,0.22); color: #e8d68d; }
|
||||||
|
.boss-group-choice.is-selected > b { border-color: var(--gold); background: rgba(232,200,114,0.14); }
|
||||||
|
.boss-group-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding-top: 3px; color: #d6e3de; }
|
||||||
|
.boss-group-heading > span { font-family: "Cinzel", serif; font-size: 10px; }
|
||||||
|
.boss-group-heading > small { color: #71867e; font-size: 7px; text-transform: uppercase; }
|
||||||
|
.boss-choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; }
|
||||||
.boss-choice { min-height: 40px; display: grid; grid-template-columns: 26px 1fr 14px; align-items: center; gap: 7px; padding: 5px 8px; border: 1px solid var(--line); color: #dce8e3; background: rgba(6,18,16,0.82); text-align: left; }
|
.boss-choice { min-height: 40px; display: grid; grid-template-columns: 26px 1fr 14px; align-items: center; gap: 7px; padding: 5px 8px; border: 1px solid var(--line); color: #dce8e3; background: rgba(6,18,16,0.82); text-align: left; }
|
||||||
.boss-choice > i { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--boss-accent) 55%, transparent); border-radius: 50%; color: var(--boss-accent); font-size: 11px; font-style: normal; }
|
.boss-choice > i { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--boss-accent) 55%, transparent); border-radius: 50%; color: var(--boss-accent); font-size: 11px; font-style: normal; }
|
||||||
.boss-choice > span { display: grid; min-width: 0; }
|
.boss-choice > span { display: grid; min-width: 0; }
|
||||||
@@ -1991,6 +2066,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; }
|
||||||
@@ -2040,6 +2118,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: 34px; grid-template-columns: 18px minmax(0, 1fr) 18px; gap: 3px; padding: 2px 4px; }
|
||||||
|
.boss-stat-selector button > i { width: 16px; height: 16px; font-size: 7px; }
|
||||||
|
.boss-stat-selector strong { font-size: 6px; }
|
||||||
|
.boss-stat-selector small { font-size: 4px; }
|
||||||
|
.boss-stat-selector button > b { font-size: 9px; }
|
||||||
|
.leaderboard-panel > header { height: 35px; padding: 3px 6px; }
|
||||||
|
.leaderboard-panel > header small { font-size: 4px; }
|
||||||
|
.leaderboard-panel > header strong { font-size: 7px; }
|
||||||
|
.leaderboard-panel > header > b { font-size: 4px; }
|
||||||
|
.leaderboard-rows { min-height: 139px; padding: 2px 4px; }
|
||||||
|
.leaderboard-rows > div, .leaderboard-self { min-height: 25px; grid-template-columns: 20px minmax(0, 1fr) 24px; gap: 3px; padding: 2px 3px; }
|
||||||
|
.leaderboard-rows > div > b, .leaderboard-self > b { font-size: 7px; }
|
||||||
|
.leaderboard-rows strong, .leaderboard-self strong { font-size: 5px; }
|
||||||
|
.leaderboard-rows small, .leaderboard-self small { font-size: 3px; }
|
||||||
|
.leaderboard-rows em, .leaderboard-self em { font-size: 8px; }
|
||||||
|
.leaderboard-self { min-height: 29px; margin: 0 4px 3px; }
|
||||||
|
.leaderboard-status, .leaderboard-empty { min-height: 139px; padding: 7px; font-size: 5px; }
|
||||||
|
.leaderboard-empty { min-height: 130px !important; }
|
||||||
.settings-layout { gap: 7px; padding-top: 7px; }
|
.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; }
|
||||||
@@ -2058,7 +2170,15 @@ button:focus-visible {
|
|||||||
.mode-hero p { font-size: 7px; }
|
.mode-hero p { font-size: 7px; }
|
||||||
.mode-hero > span, .mode-hero > b { margin-top: 4px; font-size: 5px; }
|
.mode-hero > span, .mode-hero > b { margin-top: 4px; font-size: 5px; }
|
||||||
.boss-picker { top: 54px; right: 14px; left: 14px; gap: 3px; }
|
.boss-picker { top: 54px; right: 14px; left: 14px; gap: 3px; }
|
||||||
.boss-choice-grid { grid-template-rows: repeat(var(--boss-grid-rows), minmax(27px, auto)); gap: 3px; }
|
.boss-group-grid { gap: 3px; }
|
||||||
|
.boss-group-choice { min-height: 23px; grid-template-columns: 16px minmax(0, 1fr); gap: 3px; padding: 2px 3px; }
|
||||||
|
.boss-group-choice > b { width: 15px; height: 15px; font-size: 6px; }
|
||||||
|
.boss-group-choice strong { font-size: 5px; }
|
||||||
|
.boss-group-choice small { font-size: 4px; }
|
||||||
|
.boss-group-heading { padding-top: 1px; }
|
||||||
|
.boss-group-heading > span { font-size: 7px; }
|
||||||
|
.boss-group-heading > small { font-size: 4px; }
|
||||||
|
.boss-choice-grid { gap: 3px; }
|
||||||
.boss-picker-heading { min-height: 11px; }
|
.boss-picker-heading { min-height: 11px; }
|
||||||
.boss-picker-heading > span { font-size: 4px; }
|
.boss-picker-heading > span { font-size: 4px; }
|
||||||
.boss-picker-heading button { min-width: 42px; padding: 1px 3px; font-size: 4px; }
|
.boss-picker-heading button { min-width: 42px; padding: 1px 3px; font-size: 4px; }
|
||||||
@@ -2101,7 +2221,7 @@ button:focus-visible {
|
|||||||
.gear-mode-tabs { display: flex; gap: 4px; }
|
.gear-mode-tabs { display: flex; gap: 4px; }
|
||||||
.gear-mode-tabs button { padding: 6px 8px; color: #7f958c; font-size: 7px; font-weight: 700; text-transform: uppercase; }
|
.gear-mode-tabs button { padding: 6px 8px; color: #7f958c; font-size: 7px; font-weight: 700; text-transform: uppercase; }
|
||||||
.gear-mode-tabs button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(72,58,21,.2); }
|
.gear-mode-tabs button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(72,58,21,.2); }
|
||||||
.gear-workshop-layout { height: calc(100% - 91px); display: grid; grid-template-columns: 190px 220px 1fr; gap: 11px; padding-top: 12px; }
|
.gear-workshop-layout { box-sizing: border-box; height: calc(100% - 91px); display: grid; grid-template-columns: 190px 220px 1fr; gap: 11px; padding-top: 12px; }
|
||||||
.gear-owner-list,
|
.gear-owner-list,
|
||||||
.gear-slot-list { display: grid; align-content: start; gap: 5px; }
|
.gear-slot-list { display: grid; align-content: start; gap: 5px; }
|
||||||
.gear-owner-list button,
|
.gear-owner-list button,
|
||||||
@@ -2119,6 +2239,10 @@ button:focus-visible {
|
|||||||
.gear-slot-list button > i { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid #3d554c; color: #8fc4b1; font-style: normal; }
|
.gear-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; }
|
||||||
@@ -2132,23 +2256,27 @@ button:focus-visible {
|
|||||||
.gear-infusion-options,
|
.gear-infusion-options,
|
||||||
.gear-passive-options { display: grid; gap: 4px; margin-top: 9px; }
|
.gear-passive-options { display: grid; gap: 4px; margin-top: 9px; }
|
||||||
.gear-infusion-options button,
|
.gear-infusion-options button,
|
||||||
.gear-passive-options button { min-height: 39px; display: grid; grid-template-columns: 24px 1fr 12px; align-items: center; gap: 6px; padding: 4px 6px; text-align: left; }
|
.gear-passive-choice-list button { min-height: 39px; display: grid; grid-template-columns: 24px 1fr 12px; align-items: center; gap: 6px; padding: 4px 6px; text-align: left; }
|
||||||
.gear-infusion-options button > i,
|
.gear-infusion-options button > i,
|
||||||
.gear-passive-options button > i { color: #8fc4b1; font-size: 12px; font-style: normal; text-align: center; }
|
.gear-passive-choice-list button > i { color: #8fc4b1; font-size: 12px; font-style: normal; text-align: center; }
|
||||||
.gear-infusion-options button > span,
|
.gear-infusion-options button > span,
|
||||||
.gear-passive-options button > span { min-width: 0; display: grid; }
|
.gear-passive-choice-list button > span { min-width: 0; display: grid; }
|
||||||
.gear-infusion-options button strong,
|
.gear-infusion-options button strong,
|
||||||
.gear-passive-options button strong { overflow: hidden; color: #dbe8e3; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
.gear-passive-choice-list button strong { overflow: hidden; color: #dbe8e3; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.gear-infusion-options button small,
|
.gear-infusion-options button small,
|
||||||
.gear-passive-options button small { overflow: hidden; color: #71867e; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; }
|
.gear-passive-choice-list button small { overflow: hidden; color: #71867e; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.gear-infusion-options button > b,
|
.gear-infusion-options button > b,
|
||||||
.gear-passive-options button > b { color: var(--gold); font-size: 9px; }
|
.gear-passive-choice-list button > b { color: var(--gold); font-size: 9px; }
|
||||||
.gear-infusion-options button.is-selected { border-color: var(--gold); background: rgba(72,58,21,.16); }
|
.gear-infusion-options button.is-selected { border-color: var(--gold); background: rgba(72,58,21,.16); }
|
||||||
.gear-infusion-options button.is-equipped,
|
.gear-infusion-options button.is-equipped,
|
||||||
.gear-passive-options button.is-equipped { box-shadow: inset 3px 0 #67c89e; }
|
.gear-passive-choice-list button.is-equipped { box-shadow: inset 3px 0 #67c89e; }
|
||||||
.gear-passive-options { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.gear-passive-options > span { color: #71867e; font-size: 5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
||||||
.gear-passive-options > span { grid-column: 1 / -1; color: #71867e; font-size: 5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
.gear-passive-ability-filter { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 3px; }
|
||||||
.gear-passive-options button { min-width: 0; grid-template-columns: 18px 1fr 10px; }
|
.gear-passive-ability-filter button { min-width: 0; min-height: 22px; padding: 2px 4px; color: #829890; font-size: 6px; }
|
||||||
|
.gear-passive-ability-filter button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(72,58,21,.16); }
|
||||||
|
.gear-passive-choice-list { display: grid; gap: 3px; }
|
||||||
|
.gear-passive-choice-list button { min-width: 0; grid-template-columns: 18px 1fr 10px; }
|
||||||
|
.gear-passive-choice-list button.is-selected { border-color: #70b99d; background: rgba(46,103,81,.14); }
|
||||||
|
|
||||||
.gear-context { padding: 0 5.5% 18px; }
|
.gear-context { padding: 0 5.5% 18px; }
|
||||||
.gear-context .context-header { margin: 0 -5.8%; }
|
.gear-context .context-header { margin: 0 -5.8%; }
|
||||||
@@ -2165,6 +2293,44 @@ button:focus-visible {
|
|||||||
.gear-upgrade-action:disabled { border-color: #42554e; color: #71827c; background: #13201c; }
|
.gear-upgrade-action:disabled { border-color: #42554e; color: #71827c; background: #13201c; }
|
||||||
.gear-upgrade-action span { font: 600 clamp(10px, 2.3cqw, 14px) "Cinzel", serif; }
|
.gear-upgrade-action span { font: 600 clamp(10px, 2.3cqw, 14px) "Cinzel", serif; }
|
||||||
.gear-upgrade-action small { font-size: clamp(6px, 1.35cqw, 8px); }
|
.gear-upgrade-action small { font-size: clamp(6px, 1.35cqw, 8px); }
|
||||||
|
.gear-passive-context-action { position: absolute; right: 5.5%; bottom: 57px; left: 5.5%; min-height: 56px; display: grid; align-content: center; padding: 9px 13px; border: 1px solid #67c89e; color: #dce8e3; background: rgba(20,55,43,.72); }
|
||||||
|
.gear-passive-context-action span { font: 600 clamp(10px, 2.3cqw, 14px) "Cinzel", serif; }
|
||||||
|
.gear-passive-context-action small { color: #8aa198; font-size: clamp(6px, 1.35cqw, 8px); }
|
||||||
|
|
||||||
|
@media (min-width: 761px) and (max-width: 1000px) and (max-height: 650px) {
|
||||||
|
.gear-surface { padding: 0 12px; }
|
||||||
|
.gear-surface .front-screen-header { grid-template-columns: 125px minmax(0, 1fr) auto auto; }
|
||||||
|
.gear-mode-tabs { gap: 2px; }
|
||||||
|
.gear-mode-tabs button { padding: 3px 4px; font-size: 4px; }
|
||||||
|
.gear-workshop-layout { height: calc(100% - 81px); grid-template-columns: 27% 31% 1fr; gap: 4px; padding-top: 5px; }
|
||||||
|
.gear-owner-list,
|
||||||
|
.gear-slot-list { gap: 2px; }
|
||||||
|
.gear-owner-list button,
|
||||||
|
.gear-slot-list button { min-height: 27px; gap: 3px; padding: 2px 4px; }
|
||||||
|
.gear-slot-list button { grid-template-columns: 16px 1fr 17px; }
|
||||||
|
.gear-owner-list strong,
|
||||||
|
.gear-slot-list strong { font-size: 5px; }
|
||||||
|
.gear-owner-list small,
|
||||||
|
.gear-slot-list small { font-size: 4px; }
|
||||||
|
.gear-slot-list button > i { width: 14px; height: 14px; font-size: 6px; }
|
||||||
|
.gear-preview { padding: 6px; }
|
||||||
|
.gear-preview h2 { margin: 3px 0; font-size: 8px; }
|
||||||
|
.gear-preview p { font-size: 5px; }
|
||||||
|
.gear-preview > span { font-size: 4px; }
|
||||||
|
.gear-infusion-options,
|
||||||
|
.gear-passive-options { gap: 2px; margin-top: 3px; }
|
||||||
|
.gear-infusion-options button,
|
||||||
|
.gear-passive-choice-list button { min-height: 25px; grid-template-columns: 12px 1fr 8px; gap: 2px; padding: 2px 3px; }
|
||||||
|
.gear-infusion-options button > i,
|
||||||
|
.gear-passive-choice-list button > i { font-size: 6px; }
|
||||||
|
.gear-infusion-options button strong,
|
||||||
|
.gear-passive-choice-list button strong { font-size: 4px; }
|
||||||
|
.gear-infusion-options button small,
|
||||||
|
.gear-passive-choice-list button small,
|
||||||
|
.gear-passive-options > span { font-size: 3px; }
|
||||||
|
.gear-passive-ability-filter { gap: 2px; }
|
||||||
|
.gear-passive-ability-filter button { min-height: 14px; padding: 1px 2px; font-size: 3px; }
|
||||||
|
}
|
||||||
|
|
||||||
.reward-summary { display: grid; gap: 4px; margin: 8px 0; }
|
.reward-summary { display: grid; gap: 4px; margin: 8px 0; }
|
||||||
.reward-summary > span { padding: 5px 7px; border: 1px solid rgba(232,200,114,.25); color: #dbe8e3; background: rgba(68,54,18,.17); font-size: 8px; }
|
.reward-summary > span { padding: 5px 7px; border: 1px solid rgba(232,200,114,.25); color: #dbe8e3; background: rgba(68,54,18,.17); font-size: 8px; }
|
||||||
@@ -2191,7 +2357,13 @@ button:focus-visible {
|
|||||||
.difficulty-picker > span { font-size: 4px; }
|
.difficulty-picker > span { font-size: 4px; }
|
||||||
.mode-dungeons .mode-hero { display: none; }
|
.mode-dungeons .mode-hero { display: none; }
|
||||||
.mode-dungeons .boss-picker { top: 49px; right: 12px; left: 12px; width: auto; gap: 2px; }
|
.mode-dungeons .boss-picker { top: 49px; right: 12px; left: 12px; width: auto; gap: 2px; }
|
||||||
.mode-dungeons .boss-choice-grid { gap: 2px; }
|
.mode-dungeons .boss-group-grid, .mode-dungeons .boss-choice-grid { gap: 2px; }
|
||||||
|
.mode-dungeons .boss-group-choice { min-height: 22px; grid-template-columns: 15px minmax(0, 1fr); gap: 2px; padding: 2px; }
|
||||||
|
.mode-dungeons .boss-group-choice > b { width: 14px; height: 14px; font-size: 6px; }
|
||||||
|
.mode-dungeons .boss-group-choice strong { font-size: 5px; }
|
||||||
|
.mode-dungeons .boss-group-choice small { display: none; }
|
||||||
|
.mode-dungeons .boss-group-heading > span { font-size: 6px; }
|
||||||
|
.mode-dungeons .boss-group-heading > small { font-size: 4px; }
|
||||||
.mode-dungeons .boss-choice { min-width: 0; min-height: 27px; grid-template-columns: 15px 1fr 7px; gap: 2px; padding: 2px 3px; }
|
.mode-dungeons .boss-choice { min-width: 0; min-height: 27px; grid-template-columns: 15px 1fr 7px; gap: 2px; padding: 2px 3px; }
|
||||||
.mode-dungeons .boss-choice > i { width: 14px; height: 14px; font-size: 6px; }
|
.mode-dungeons .boss-choice > i { width: 14px; height: 14px; font-size: 6px; }
|
||||||
.mode-dungeons .boss-choice strong { overflow: hidden; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; }
|
.mode-dungeons .boss-choice strong { overflow: hidden; font-size: 5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
@@ -2224,12 +2396,14 @@ button:focus-visible {
|
|||||||
.gear-infusion-options,
|
.gear-infusion-options,
|
||||||
.gear-passive-options { gap: 2px; margin-top: 3px; }
|
.gear-passive-options { gap: 2px; margin-top: 3px; }
|
||||||
.gear-infusion-options button,
|
.gear-infusion-options button,
|
||||||
.gear-passive-options button { min-height: 25px; grid-template-columns: 12px 1fr 8px; gap: 2px; padding: 2px 3px; }
|
.gear-passive-choice-list button { min-height: 25px; grid-template-columns: 12px 1fr 8px; gap: 2px; padding: 2px 3px; }
|
||||||
.gear-infusion-options button > i,
|
.gear-infusion-options button > i,
|
||||||
.gear-passive-options button > i { font-size: 6px; }
|
.gear-passive-choice-list button > i { font-size: 6px; }
|
||||||
.gear-infusion-options button strong,
|
.gear-infusion-options button strong,
|
||||||
.gear-passive-options button strong { font-size: 4px; }
|
.gear-passive-choice-list button strong { font-size: 4px; }
|
||||||
.gear-infusion-options button small,
|
.gear-infusion-options button small,
|
||||||
.gear-passive-options button small,
|
.gear-passive-choice-list button small,
|
||||||
.gear-passive-options > span { font-size: 3px; }
|
.gear-passive-options > span { font-size: 3px; }
|
||||||
|
.gear-passive-ability-filter { gap: 2px; }
|
||||||
|
.gear-passive-ability-filter button { min-height: 14px; padding: 1px 2px; font-size: 3px; }
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user