Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 207fcd1a15 | |||
| fd6a1ce3c7 | |||
| f8b98e6b23 | |||
| fc7c6488ea | |||
| ba6d3b614e | |||
| 88874933c3 | |||
| bf12aefeeb | |||
| 814eb1998d | |||
| 7fe62d8c82 | |||
| 3a8d5ad8c5 | |||
| a604569a2f |
@@ -0,0 +1,8 @@
|
|||||||
|
# Project Notes
|
||||||
|
|
||||||
|
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
|
||||||
|
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
|
||||||
|
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
|
||||||
|
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
|
||||||
|
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
||||||
|
- Apply game changes to both web version and mobile app version.
|
||||||
+221
@@ -43,6 +43,227 @@ Keep the game server bound to `127.0.0.1`. Set `TRUST_PROXY=1` only when the
|
|||||||
server can be reached solely through your local reverse proxy. This lets account
|
server can be reached solely through your local reverse proxy. This lets account
|
||||||
limits use the visitor's public IP instead of the proxy's address.
|
limits use the visitor's public IP instead of the proxy's address.
|
||||||
|
|
||||||
|
## TrueNAS single-container hosting
|
||||||
|
|
||||||
|
### TrueNAS SCALE runbook
|
||||||
|
|
||||||
|
This is the simplest TrueNAS setup. One container serves the browser game,
|
||||||
|
auth routes, game API routes, and one SQLite database. Use this when you want
|
||||||
|
`iwanttoheal.phenomrom.com` to host the playable browser version and you want
|
||||||
|
code updates to be a Git pull plus app restart.
|
||||||
|
|
||||||
|
Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**.
|
||||||
|
|
||||||
|
Repository:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://git.whoagland.com/phenom/i-want-to-heal.git
|
||||||
|
```
|
||||||
|
|
||||||
|
TrueNAS paths:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
/mnt/usbssds/apps/iwanttoheal/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the app directory and clone the repo:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal
|
||||||
|
sudo git clone https://git.whoagland.com/phenom/i-want-to-heal.git app
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the clone was run with `sudo`, give the normal TrueNAS user ownership:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo chown -R truenas_admin:truenas_admin /mnt/usbssds/apps/iwanttoheal
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the persistent data folder:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p /mnt/usbssds/apps/iwanttoheal/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Check that the production server file exists:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls /mnt/usbssds/apps/iwanttoheal/app/server/production.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
If that file is missing, push the latest code to `git.whoagland.com` from the
|
||||||
|
development machine, then pull on TrueNAS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
If Git fails with `chmod ... Operation not permitted`, do not use a media or SMB
|
||||||
|
dataset for the repo. Git needs normal file locking and chmod behavior. Create or
|
||||||
|
use a dedicated apps dataset and clone under `/mnt/usbssds/apps/...`.
|
||||||
|
|
||||||
|
### TrueNAS app YAML
|
||||||
|
|
||||||
|
In TrueNAS:
|
||||||
|
|
||||||
|
1. Open **Apps**.
|
||||||
|
2. Open **Discover**.
|
||||||
|
3. Click the three-dot menu.
|
||||||
|
4. Choose **Install via YAML**.
|
||||||
|
5. Name the app `iwanttoheal`.
|
||||||
|
6. Paste this YAML:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
iwanttoheal:
|
||||||
|
image: node:24-bookworm-slim
|
||||||
|
working_dir: /app
|
||||||
|
command: sh -lc "npm ci && npm run db:init && npm run build && npm start"
|
||||||
|
environment:
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: "4173"
|
||||||
|
TRUST_PROXY: "1"
|
||||||
|
COOKIE_SECURE: "1"
|
||||||
|
CORS_ORIGINS: "http://localhost,https://localhost,capacitor://localhost,https://iwanttoheal.phenomrom.com,https://auth.phenomrom.com"
|
||||||
|
ports:
|
||||||
|
- "4173:4173"
|
||||||
|
volumes:
|
||||||
|
- /mnt/usbssds/apps/iwanttoheal/app:/app
|
||||||
|
- /mnt/usbssds/apps/iwanttoheal/data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
The app listens inside Docker on port `4173`. The database lives at
|
||||||
|
`/mnt/usbssds/apps/iwanttoheal/data/game.db` because that host directory is
|
||||||
|
mounted into the container as `/app/data`. This is persistent runtime data, not
|
||||||
|
code. Do not commit it and do not copy the Mac `data/game.db` over it during
|
||||||
|
deploys.
|
||||||
|
|
||||||
|
The startup command installs dependencies, applies schema/static-content
|
||||||
|
updates, builds the web app, and starts the production server.
|
||||||
|
|
||||||
|
Test the local TrueNAS service:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://TRUENAS-IP:4173/api/auth/session
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"account":null,"profile":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reverse proxy
|
||||||
|
|
||||||
|
Point `iwanttoheal.phenomrom.com` at the TrueNAS app through HTTPS. Do not expose
|
||||||
|
port `4173` directly to the internet. Put Caddy or another reverse proxy in
|
||||||
|
front:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
iwanttoheal.phenomrom.com {
|
||||||
|
reverse_proxy TRUENAS-IP:4173
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.phenomrom.com {
|
||||||
|
reverse_proxy TRUENAS-IP:4173
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both hostnames can point at the same container. `iwanttoheal.phenomrom.com`
|
||||||
|
serves the browser game. `auth.phenomrom.com` stays available as an auth URL for
|
||||||
|
Android or other clients that need a dedicated auth hostname.
|
||||||
|
|
||||||
|
DNS should point both hostnames at the public IP or dynamic DNS name that reaches
|
||||||
|
the reverse proxy. Forward public ports `80` and `443` to the reverse proxy host.
|
||||||
|
|
||||||
|
Test the public game and auth URLs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl https://iwanttoheal.phenomrom.com
|
||||||
|
curl https://auth.phenomrom.com/api/auth/session
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected auth response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"account":null,"profile":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
### App build config
|
||||||
|
|
||||||
|
For the hosted browser game, no separate auth build setting is needed. The web
|
||||||
|
app can call same-origin routes like `/api/auth/login` and `/api/profile`.
|
||||||
|
|
||||||
|
For an Android build that should use the TrueNAS-hosted game API, build with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run android:apk:truenas
|
||||||
|
```
|
||||||
|
|
||||||
|
If you intentionally want Android auth calls to use `auth.phenomrom.com`, also
|
||||||
|
set `VITE_AUTH_API_BASE_URL=https://auth.phenomrom.com`. Otherwise, leave it
|
||||||
|
unset and auth uses the same base URL as the game API.
|
||||||
|
|
||||||
|
Android runs the bundled web app from a local Capacitor origin, not from
|
||||||
|
`iwanttoheal.phenomrom.com`. The hosted server must allow that origin through
|
||||||
|
CORS, which is why the TrueNAS YAML includes `http://localhost`,
|
||||||
|
`https://localhost`, and `capacitor://localhost`.
|
||||||
|
|
||||||
|
### Updating the TrueNAS game app
|
||||||
|
|
||||||
|
Push changes from the development machine to `git.whoagland.com`, then pull them
|
||||||
|
on TrueNAS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Before restarting, back up the persistent database:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
|
||||||
|
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the `iwanttoheal` app in the TrueNAS Apps UI after pulling. The app
|
||||||
|
command runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start` on
|
||||||
|
startup, so dependency, schema, and browser bundle changes are applied each time
|
||||||
|
the container restarts.
|
||||||
|
|
||||||
|
`npm run db:init` updates schema and seeded static game content. It should not
|
||||||
|
erase accounts, characters, inventory, or save progress. Character resets are
|
||||||
|
separate manual operations and should only be run intentionally.
|
||||||
|
|
||||||
|
Normal update workflow:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# development machine
|
||||||
|
git add .
|
||||||
|
git commit -m "Update game"
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
# TrueNAS shell
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git pull
|
||||||
|
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
|
||||||
|
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the TrueNAS app.
|
||||||
|
|
||||||
|
For the shorter checklist, see [docs/push-updates.md](docs/push-updates.md).
|
||||||
|
|
||||||
|
### Existing auth-only app
|
||||||
|
|
||||||
|
If `iwanttoheal-auth` was already created during earlier testing, the simplest
|
||||||
|
path is to stop that app and use the single `iwanttoheal` app above. The single
|
||||||
|
container serves both domains and avoids two processes sharing one SQLite file.
|
||||||
|
|
||||||
## Account limits
|
## Account limits
|
||||||
|
|
||||||
Registration permits one account per public IP by default. Login and API rate
|
Registration permits one account per public IP by default. Login and API rate
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -57,13 +57,13 @@ For an online production build, see [DEPLOYMENT.md](DEPLOYMENT.md).
|
|||||||
- Dungeon XP rewards, level-up handling, talent-point awards, and ability unlocks
|
- Dungeon XP rewards, level-up handling, talent-point awards, and ability unlocks
|
||||||
- SQLite-backed class talent trees with ranks, tiers, prerequisites, refunds,
|
- SQLite-backed class talent trees with ranks, tiers, prerequisites, refunds,
|
||||||
and immediate persistence
|
and immediate persistence
|
||||||
- Seven equipment slots, starter item-level 1 gear, inventory comparison, and
|
- Nine equipment slots, empty starter inventory, craftable item-level 1 gear,
|
||||||
persistent equipping
|
inventory comparison, and persistent equipping
|
||||||
- Aggregate item level, healing power, and resource bonuses that affect combat
|
- Aggregate item level, healing power, and resource bonuses that affect combat
|
||||||
- Five SQLite-authored dungeon difficulty tiers with level gates, combat
|
- Four playable SQLite-authored content tiers at item levels 1, 10, 20, and 25
|
||||||
scaling, XP multipliers, and item-level reward bands
|
with level gates, combat scaling, XP multipliers, and reward bands
|
||||||
- Encounter-specific weighted loot tables for every difficulty, with authored
|
- Gear progression through item levels 1, 5, 10, 15, 20, and 25 with
|
||||||
drop chances, slot pools, and item-level 5 through 25 reward variants
|
boss-coin crafting and upgrade steps
|
||||||
- One live loot roll per defeated encounter, shown in the combat log and
|
- One live loot roll per defeated encounter, shown in the combat log and
|
||||||
dungeon-complete summary
|
dungeon-complete summary
|
||||||
- Atomic inventory awards with retry-safe roll records and stacked duplicate
|
- Atomic inventory awards with retry-safe roll records and stacked duplicate
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 49
|
||||||
versionName "1.0"
|
versionName "1.0.31"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -24,6 +24,18 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def invalidAndroidResCopies = tasks.register('removeInvalidAndroidResCopies', Delete) {
|
||||||
|
delete fileTree("${projectDir}/src/main/res") {
|
||||||
|
include '**/* *.*'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.matching { task ->
|
||||||
|
task.name.startsWith('merge') && task.name.endsWith('Resources')
|
||||||
|
}.configureEach {
|
||||||
|
dependsOn(invalidAndroidResCopies)
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
flatDir{
|
flatDir{
|
||||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
|||||||
@@ -2,17 +2,25 @@ package com.warren.iwanttoheal;
|
|||||||
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.os.SystemClock;
|
||||||
import android.view.KeyEvent;
|
import android.view.KeyEvent;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||||
|
|
||||||
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
|
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
|
||||||
|
private static final long DPAD_THROTTLE_MS = 220;
|
||||||
|
private long lastDpadDispatchAt = 0;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
|
clearWebViewServiceWorkers();
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.getWebView().clearCache(true);
|
||||||
|
}
|
||||||
loadIntentUrl();
|
loadIntentUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +55,25 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void clearWebViewServiceWorkers() {
|
||||||
|
File webViewData = new File(getApplicationInfo().dataDir, "app_webview");
|
||||||
|
deleteIfExists(new File(webViewData, "Default/Service Worker"));
|
||||||
|
deleteIfExists(new File(webViewData, "Service Worker"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteIfExists(File file) {
|
||||||
|
if (!file.exists()) return;
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
File[] children = file.listFiles();
|
||||||
|
if (children != null) {
|
||||||
|
for (File child : children) {
|
||||||
|
deleteIfExists(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.delete();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||||
String token = controllerToken(event.getKeyCode());
|
String token = controllerToken(event.getKeyCode());
|
||||||
@@ -54,6 +81,7 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
|
|
||||||
if (event.getAction() == KeyEvent.ACTION_DOWN) {
|
if (event.getAction() == KeyEvent.ACTION_DOWN) {
|
||||||
boolean repeat = event.getRepeatCount() > 0;
|
boolean repeat = event.getRepeatCount() > 0;
|
||||||
|
if (isDpadToken(token) && shouldThrottleDpad()) return true;
|
||||||
String script =
|
String script =
|
||||||
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
|
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
|
||||||
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
||||||
@@ -64,6 +92,20 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean shouldThrottleDpad() {
|
||||||
|
long now = SystemClock.uptimeMillis();
|
||||||
|
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true;
|
||||||
|
lastDpadDispatchAt = now;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDpadToken(String token) {
|
||||||
|
return token.equals("Button12")
|
||||||
|
|| token.equals("Button13")
|
||||||
|
|| token.equals("Button14")
|
||||||
|
|| token.equals("Button15");
|
||||||
|
}
|
||||||
|
|
||||||
private String controllerToken(int keyCode) {
|
private String controllerToken(int keyCode) {
|
||||||
switch (keyCode) {
|
switch (keyCode) {
|
||||||
case KeyEvent.KEYCODE_BUTTON_A:
|
case KeyEvent.KEYCODE_BUTTON_A:
|
||||||
|
|||||||
@@ -66,9 +66,10 @@ public class DualScreenPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String gameUrl = bridge.getLocalUrl();
|
String gameUrl = bridge.getLocalUrl();
|
||||||
|
String topGameUrl = gameUrl + "/?display=top";
|
||||||
String controlsUrl = gameUrl + "/?display=bottom";
|
String controlsUrl = gameUrl + "/?display=bottom";
|
||||||
String targetUrl = launchPlan.targetIsTopDisplay ? gameUrl : controlsUrl;
|
String targetUrl = launchPlan.targetIsTopDisplay ? topGameUrl : controlsUrl;
|
||||||
String currentUrl = launchPlan.currentIsTopDisplay ? gameUrl : controlsUrl;
|
String currentUrl = launchPlan.currentIsTopDisplay ? topGameUrl : controlsUrl;
|
||||||
|
|
||||||
closePresentation();
|
closePresentation();
|
||||||
presentation = new TopDisplayPresentation(
|
presentation = new TopDisplayPresentation(
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS dungeons (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
recommended_level INTEGER NOT NULL DEFAULT 1,
|
recommended_level INTEGER NOT NULL DEFAULT 1,
|
||||||
content_type TEXT NOT NULL DEFAULT 'dungeon' CHECK (content_type IN ('dungeon', 'raid')),
|
content_type TEXT NOT NULL DEFAULT 'dungeon' CHECK (content_type IN ('dungeon', 'raid')),
|
||||||
party_size INTEGER NOT NULL DEFAULT 5,
|
party_size INTEGER NOT NULL DEFAULT 6,
|
||||||
completion_item_level INTEGER,
|
completion_item_level INTEGER,
|
||||||
experience_reward INTEGER NOT NULL DEFAULT 100,
|
experience_reward INTEGER NOT NULL DEFAULT 100,
|
||||||
description TEXT NOT NULL
|
description TEXT NOT NULL
|
||||||
|
|||||||
+437
-46
@@ -5,8 +5,8 @@ INSERT OR IGNORE INTO locations (id, slug, name, description) VALUES
|
|||||||
INSERT OR IGNORE INTO dungeons
|
INSERT OR IGNORE INTO dungeons
|
||||||
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
|
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
|
||||||
VALUES
|
VALUES
|
||||||
(1, 1, 'ashen-halls', 'The Ashen Halls', 1, 'dungeon', 5, NULL, 125, 'Break the cinder cult before the old furnace awakens.'),
|
(1, 1, 'ashen-halls', 'The Ashen Halls', 1, 'dungeon', 6, NULL, 125, 'Break the cinder cult before the old furnace awakens.'),
|
||||||
(2, 2, 'citadel-of-the-ember-crown', 'Citadel of the Ember Crown', 1, 'raid', 10, 10, 175, 'Lead ten allies through the caldera and break the Ember Crown across three phases.');
|
(2, 2, 'citadel-of-the-ember-crown', 'Citadel of the Ember Crown', 1, 'raid', 18, 10, 175, 'Lead eighteen allies through the caldera and break the Ember Crown across three phases.');
|
||||||
|
|
||||||
UPDATE dungeons
|
UPDATE dungeons
|
||||||
SET slug = 'bulldrome-hunting-ground',
|
SET slug = 'bulldrome-hunting-ground',
|
||||||
@@ -14,39 +14,46 @@ SET slug = 'bulldrome-hunting-ground',
|
|||||||
location_id = 1,
|
location_id = 1,
|
||||||
recommended_level = 1,
|
recommended_level = 1,
|
||||||
content_type = 'dungeon',
|
content_type = 'dungeon',
|
||||||
party_size = 5,
|
party_size = 6,
|
||||||
completion_item_level = NULL,
|
completion_item_level = NULL,
|
||||||
experience_reward = 125,
|
experience_reward = 125,
|
||||||
description = 'A three-boss hunt featuring Bulldrome, Yian Kut-Ku, and Rathian.'
|
description = 'A three-boss hunt featuring Bulldrome, Yian Kut-Ku, and Rathian.'
|
||||||
WHERE id = 1;
|
WHERE id = 1;
|
||||||
UPDATE dungeons SET party_size = 10, completion_item_level = NULL, experience_reward = 175
|
UPDATE dungeons SET party_size = 18, completion_item_level = NULL, experience_reward = 175
|
||||||
WHERE slug = 'citadel-of-the-ember-crown';
|
WHERE slug = 'citadel-of-the-ember-crown';
|
||||||
|
|
||||||
INSERT OR IGNORE INTO difficulties
|
INSERT OR IGNORE INTO difficulties
|
||||||
(id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description)
|
(id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description)
|
||||||
VALUES
|
VALUES
|
||||||
(1, 'initiate', 'Initiate', 5, 1, 1.0, 1.0, 1.0, 'Entry-level dungeon difficulty.'),
|
(1, 'initiate', 'Initiate', 1, 1, 0.8, 0.8, 1.0, 'Entry-level dungeon difficulty for crafting the first real set.'),
|
||||||
(2, 'veteran', 'Veteran', 10, 5, 1.35, 1.2, 1.5, 'Enemies deal more damage and drop stronger gear.'),
|
(2, 'veteran', 'Veteran', 10, 10, 1.45, 1.25, 2.0, 'A major step up that rewards refined gear components.'),
|
||||||
(3, 'champion', 'Champion', 15, 10, 1.7, 1.45, 2.2, 'Demanding encounters for developed characters.'),
|
(3, 'champion', 'Champion', 15, 15, 1.7, 1.45, 2.2, 'Gear-only upgrade tier between Veteran and Mythic.'),
|
||||||
(4, 'mythic', 'Mythic', 20, 15, 2.1, 1.75, 3.0, 'Endgame dungeon difficulty.'),
|
(4, 'mythic', 'Mythic', 20, 20, 2.25, 1.85, 3.5, 'Endgame dungeon difficulty.'),
|
||||||
(5, 'ascendant', 'Ascendant', 25, 20, 2.6, 2.1, 4.0, 'The current pinnacle difficulty.'),
|
(5, 'ascendant', 'Ascendant', 25, 25, 2.8, 2.25, 4.5, 'The current pinnacle difficulty.'),
|
||||||
(101, 'raid-normal', 'Normal', 7, 1, 1.0, 1.0, 1.25, 'The opening raid difficulty, tuned for a ten-player party.');
|
(101, 'raid-normal', 'Normal', 7, 1, 1.0, 1.0, 1.25, 'The opening raid difficulty, tuned for an eighteen-player party.');
|
||||||
|
|
||||||
UPDATE difficulties SET
|
UPDATE difficulties SET
|
||||||
dropped_item_level = CASE slug
|
dropped_item_level = CASE slug
|
||||||
WHEN 'raid-normal' THEN 10 ELSE dropped_item_level END,
|
WHEN 'initiate' THEN 1 WHEN 'raid-normal' THEN 10 ELSE dropped_item_level END,
|
||||||
unlock_level = CASE slug
|
unlock_level = CASE slug
|
||||||
WHEN 'initiate' THEN 1 WHEN 'veteran' THEN 5 WHEN 'champion' THEN 10
|
WHEN 'initiate' THEN 1 WHEN 'veteran' THEN 10 WHEN 'champion' THEN 15
|
||||||
WHEN 'mythic' THEN 15 WHEN 'ascendant' THEN 20 ELSE unlock_level END,
|
WHEN 'mythic' THEN 20 WHEN 'ascendant' THEN 25 ELSE unlock_level END,
|
||||||
health_multiplier = CASE slug
|
health_multiplier = CASE slug
|
||||||
WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 1.35 WHEN 'champion' THEN 1.7
|
WHEN 'initiate' THEN 0.8 WHEN 'veteran' THEN 1.45 WHEN 'champion' THEN 1.7
|
||||||
WHEN 'mythic' THEN 2.1 WHEN 'ascendant' THEN 2.6 ELSE health_multiplier END,
|
WHEN 'mythic' THEN 2.25 WHEN 'ascendant' THEN 2.8 ELSE health_multiplier END,
|
||||||
damage_multiplier = CASE slug
|
damage_multiplier = CASE slug
|
||||||
WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 1.2 WHEN 'champion' THEN 1.45
|
WHEN 'initiate' THEN 0.8 WHEN 'veteran' THEN 1.25 WHEN 'champion' THEN 1.45
|
||||||
WHEN 'mythic' THEN 1.75 WHEN 'ascendant' THEN 2.1 ELSE damage_multiplier END,
|
WHEN 'mythic' THEN 1.85 WHEN 'ascendant' THEN 2.25 ELSE damage_multiplier END,
|
||||||
experience_multiplier = CASE slug
|
experience_multiplier = CASE slug
|
||||||
WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 1.5 WHEN 'champion' THEN 2.2
|
WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 2.0 WHEN 'champion' THEN 2.2
|
||||||
WHEN 'mythic' THEN 3.0 WHEN 'ascendant' THEN 4.0 ELSE experience_multiplier END;
|
WHEN 'mythic' THEN 3.5 WHEN 'ascendant' THEN 4.5 ELSE experience_multiplier END,
|
||||||
|
description = CASE slug
|
||||||
|
WHEN 'initiate' THEN 'Entry-level dungeon difficulty for crafting the first real set.'
|
||||||
|
WHEN 'veteran' THEN 'A major step up that rewards refined gear components.'
|
||||||
|
WHEN 'champion' THEN 'Gear-only upgrade tier between Veteran and Mythic.'
|
||||||
|
WHEN 'mythic' THEN 'Endgame dungeon difficulty.'
|
||||||
|
WHEN 'ascendant' THEN 'The current pinnacle difficulty.'
|
||||||
|
ELSE description END;
|
||||||
|
|
||||||
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id) VALUES
|
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id) VALUES
|
||||||
(1, 1),
|
(1, 1),
|
||||||
@@ -108,7 +115,7 @@ VALUES
|
|||||||
(11, 12, 'Brand of Sacrifice', 'dispellable_dot', 8.4, 9, 'Brands a target with burning sigils.'),
|
(11, 12, 'Brand of Sacrifice', 'dispellable_dot', 8.4, 9, 'Brands a target with burning sigils.'),
|
||||||
(20, 22, 'Furnace Blast', 'party_damage', 6.3, 16, 'The furnace vents superheated air across the party.'),
|
(20, 22, 'Furnace Blast', 'party_damage', 6.3, 16, 'The furnace vents superheated air across the party.'),
|
||||||
(21, 22, 'Melting Touch', 'dispellable_dot', 9.1, 11, 'A target begins to melt from intense heat.'),
|
(21, 22, 'Melting Touch', 'dispellable_dot', 9.1, 11, 'A target begins to melt from intense heat.'),
|
||||||
(100, 102, 'Caldera Shockwave', 'party_damage', 5.6, 14, 'A shockwave rolls across all ten raiders.'),
|
(100, 102, 'Caldera Shockwave', 'party_damage', 5.6, 14, 'A shockwave rolls across all eighteen raiders.'),
|
||||||
(101, 102, 'Gatebrand', 'dispellable_dot', 8.2, 9, 'Brands one raider with a seal of living fire.'),
|
(101, 102, 'Gatebrand', 'dispellable_dot', 8.2, 9, 'Brands one raider with a seal of living fire.'),
|
||||||
(102, 105, 'Pillar of Judgment', 'party_damage', 5.2, 16, 'Columns of flame erupt beneath the raid.'),
|
(102, 105, 'Pillar of Judgment', 'party_damage', 5.2, 16, 'Columns of flame erupt beneath the raid.'),
|
||||||
(103, 105, 'Inquisitor''s Brand', 'dispellable_dot', 7.8, 10, 'A burning brand persists until cleansed.'),
|
(103, 105, 'Inquisitor''s Brand', 'dispellable_dot', 7.8, 10, 'A burning brand persists until cleansed.'),
|
||||||
@@ -127,22 +134,22 @@ INSERT OR IGNORE INTO spells
|
|||||||
VALUES
|
VALUES
|
||||||
(1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'),
|
(1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'),
|
||||||
(2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'),
|
(2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'),
|
||||||
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to every living party member.'),
|
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to up to 4 injured party members.'),
|
||||||
(4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'),
|
(4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'),
|
||||||
(5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'),
|
(5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'),
|
||||||
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for the entire party.'),
|
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for up to 4 injured allies.'),
|
||||||
(7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'),
|
(7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'),
|
||||||
(8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'),
|
(8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'),
|
||||||
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods the party with the full strength of dawn.'),
|
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods up to 4 injured allies with the full strength of dawn.'),
|
||||||
(20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'),
|
(20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'),
|
||||||
(21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'),
|
(21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'),
|
||||||
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads through the party.'),
|
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads to up to 4 injured allies.'),
|
||||||
(23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'),
|
(23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'),
|
||||||
(24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'),
|
(24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'),
|
||||||
(25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'),
|
(25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'),
|
||||||
(30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'),
|
(30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'),
|
||||||
(31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'),
|
(31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'),
|
||||||
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links the party through a shared healing pattern.'),
|
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links up to 4 injured allies through a shared healing pattern.'),
|
||||||
(33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'),
|
(33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'),
|
||||||
(34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'),
|
(34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'),
|
||||||
(35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.');
|
(35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.');
|
||||||
@@ -345,6 +352,9 @@ DELETE FROM crafting_recipes;
|
|||||||
INSERT INTO crafting_recipes
|
INSERT INTO crafting_recipes
|
||||||
(id, item_id, difficulty_id, source_dungeon_id, source_encounter_id)
|
(id, item_id, difficulty_id, source_dungeon_id, source_encounter_id)
|
||||||
VALUES
|
VALUES
|
||||||
|
(901, 101, 1, 1, 3), (902, 102, 1, 1, 3), (903, 103, 1, 1, 3),
|
||||||
|
(904, 104, 1, 1, 12), (905, 105, 1, 1, 12), (906, 106, 1, 1, 12),
|
||||||
|
(907, 100, 1, 1, 22), (908, 108, 1, 1, 22), (909, 109, 1, 1, 22),
|
||||||
(1001, 5, 1, 1, 3), (1002, 2, 1, 1, 3), (1003, 6, 1, 1, 3),
|
(1001, 5, 1, 1, 3), (1002, 2, 1, 1, 3), (1003, 6, 1, 1, 3),
|
||||||
(1004, 4, 1, 1, 12), (1005, 1, 1, 1, 12), (1006, 7, 1, 1, 12),
|
(1004, 4, 1, 1, 12), (1005, 1, 1, 1, 12), (1006, 7, 1, 1, 12),
|
||||||
(1007, 3, 1, 1, 22), (1008, 8, 1, 1, 22), (1009, 9, 1, 1, 22),
|
(1007, 3, 1, 1, 22), (1008, 8, 1, 1, 22), (1009, 9, 1, 1, 22),
|
||||||
@@ -429,6 +439,173 @@ INSERT OR IGNORE INTO character_inventory (character_id, item_id, quantity, equi
|
|||||||
(3, 100, 1, 1), (3, 101, 1, 1), (3, 102, 1, 1), (3, 103, 1, 1),
|
(3, 100, 1, 1), (3, 101, 1, 1), (3, 102, 1, 1), (3, 103, 1, 1),
|
||||||
(3, 104, 1, 1), (3, 108, 1, 1), (3, 105, 1, 1), (3, 109, 1, 1), (3, 106, 1, 1), (3, 107, 1, 0);
|
(3, 104, 1, 1), (3, 108, 1, 1), (3, 105, 1, 1), (3, 109, 1, 1), (3, 106, 1, 1), (3, 107, 1, 0);
|
||||||
|
|
||||||
|
DELETE FROM character_inventory
|
||||||
|
WHERE character_id IN (1, 2, 3)
|
||||||
|
AND item_id BETWEEN 100 AND 109;
|
||||||
|
|
||||||
|
-- Coin gearing override: every boss/difficulty drops one boss coin, and each
|
||||||
|
-- craft costs the target item level in that source boss coin.
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET source_dungeon_id = 1,
|
||||||
|
source_encounter_id = 3
|
||||||
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
|
||||||
|
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET source_dungeon_id = 1,
|
||||||
|
source_encounter_id = 12
|
||||||
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
|
||||||
|
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET source_dungeon_id = 1,
|
||||||
|
source_encounter_id = 22
|
||||||
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace'));
|
||||||
|
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET difficulty_id = CASE
|
||||||
|
(SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id)
|
||||||
|
WHEN 1 THEN 1
|
||||||
|
WHEN 5 THEN 1
|
||||||
|
WHEN 10 THEN 2
|
||||||
|
WHEN 15 THEN 2
|
||||||
|
WHEN 20 THEN 4
|
||||||
|
WHEN 25 THEN 5
|
||||||
|
ELSE difficulty_id
|
||||||
|
END
|
||||||
|
WHERE id BETWEEN 901 AND 1409;
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET rarity = CASE item_level
|
||||||
|
WHEN 1 THEN 'common'
|
||||||
|
WHEN 10 THEN 'uncommon'
|
||||||
|
WHEN 20 THEN 'epic'
|
||||||
|
WHEN 25 THEN 'legendary'
|
||||||
|
ELSE rarity
|
||||||
|
END
|
||||||
|
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET name = (
|
||||||
|
SELECT
|
||||||
|
CASE items.item_level
|
||||||
|
WHEN 1 THEN 'Raw '
|
||||||
|
WHEN 10 THEN 'Green '
|
||||||
|
WHEN 20 THEN 'Purple '
|
||||||
|
WHEN 25 THEN 'Orange '
|
||||||
|
ELSE ''
|
||||||
|
END
|
||||||
|
|| encounters.name || ' '
|
||||||
|
|| CASE items.slot
|
||||||
|
WHEN 'weapon' THEN 'Weapon'
|
||||||
|
WHEN 'helmet' THEN 'Helmet'
|
||||||
|
WHEN 'chest' THEN 'Chest'
|
||||||
|
WHEN 'gloves' THEN 'Gloves'
|
||||||
|
WHEN 'boots' THEN 'Boots'
|
||||||
|
WHEN 'pants' THEN 'Pants'
|
||||||
|
WHEN 'ring' THEN 'Ring'
|
||||||
|
WHEN 'necklace' THEN 'Necklace'
|
||||||
|
WHEN 'trinket' THEN 'Trinket'
|
||||||
|
ELSE items.name
|
||||||
|
END
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||||
|
WHERE crafting_recipes.item_id = items.id
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
description = (
|
||||||
|
SELECT 'Crafted with ' || encounters.name || ' coins.'
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||||
|
WHERE crafting_recipes.item_id = items.id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||||
|
|
||||||
|
CREATE TEMP TABLE IF NOT EXISTS coin_sources (
|
||||||
|
item_id INTEGER PRIMARY KEY,
|
||||||
|
encounter_id INTEGER NOT NULL,
|
||||||
|
difficulty_id INTEGER NOT NULL,
|
||||||
|
item_level INTEGER NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
rarity TEXT NOT NULL,
|
||||||
|
glyph TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM coin_sources;
|
||||||
|
|
||||||
|
INSERT INTO coin_sources
|
||||||
|
SELECT
|
||||||
|
280000 + encounters.id * 100 + difficulties.dropped_item_level,
|
||||||
|
encounters.id,
|
||||||
|
difficulties.id,
|
||||||
|
difficulties.dropped_item_level,
|
||||||
|
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
|
||||||
|
CASE difficulties.dropped_item_level
|
||||||
|
WHEN 1 THEN 'Raw '
|
||||||
|
WHEN 5 THEN 'Honed '
|
||||||
|
WHEN 10 THEN 'Green '
|
||||||
|
WHEN 15 THEN 'Blue '
|
||||||
|
WHEN 20 THEN 'Purple '
|
||||||
|
WHEN 25 THEN 'Orange '
|
||||||
|
ELSE ''
|
||||||
|
END || encounters.name || ' Coin',
|
||||||
|
CASE difficulties.dropped_item_level
|
||||||
|
WHEN 1 THEN 'common'
|
||||||
|
WHEN 5 THEN 'common'
|
||||||
|
WHEN 10 THEN 'uncommon'
|
||||||
|
WHEN 15 THEN 'rare'
|
||||||
|
WHEN 20 THEN 'epic'
|
||||||
|
WHEN 25 THEN 'legendary'
|
||||||
|
ELSE 'common'
|
||||||
|
END,
|
||||||
|
'$',
|
||||||
|
'A boss coin from ' || encounters.name || ' used for item level '
|
||||||
|
|| difficulties.dropped_item_level || ' crafting.'
|
||||||
|
FROM encounters
|
||||||
|
JOIN dungeon_difficulties ON dungeon_difficulties.dungeon_id = encounters.dungeon_id
|
||||||
|
JOIN difficulties ON difficulties.id = dungeon_difficulties.difficulty_id
|
||||||
|
WHERE encounters.encounter_type = 'boss';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO items
|
||||||
|
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
|
||||||
|
SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description
|
||||||
|
FROM coin_sources;
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
slot = 'component',
|
||||||
|
rarity = (SELECT rarity FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
item_level = (SELECT item_level FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
healing_power = 0,
|
||||||
|
max_resource_bonus = 0,
|
||||||
|
glyph = (SELECT glyph FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
description = (SELECT description FROM coin_sources WHERE coin_sources.item_id = items.id)
|
||||||
|
WHERE id IN (SELECT item_id FROM coin_sources);
|
||||||
|
|
||||||
|
DELETE FROM encounter_loot;
|
||||||
|
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
|
||||||
|
SELECT encounter_id, item_id, difficulty_id, 100, 1.0
|
||||||
|
FROM coin_sources;
|
||||||
|
|
||||||
|
DELETE FROM crafting_recipe_components;
|
||||||
|
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
||||||
|
SELECT
|
||||||
|
crafting_recipes.id,
|
||||||
|
coin_sources.item_id,
|
||||||
|
items.item_level
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
|
JOIN coin_sources
|
||||||
|
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
|
||||||
|
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
|
||||||
|
|
||||||
INSERT OR IGNORE INTO talents
|
INSERT OR IGNORE INTO talents
|
||||||
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
|
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
|
||||||
VALUES
|
VALUES
|
||||||
@@ -547,7 +724,6 @@ INSERT INTO generated_loot_tiers
|
|||||||
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
|
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
|
||||||
VALUES
|
VALUES
|
||||||
(10, 3, 2, 2, 101, 1100, 2),
|
(10, 3, 2, 2, 101, 1100, 2),
|
||||||
(15, 4, 5, 3, 103, 1200, 3),
|
|
||||||
(20, 6, 7, 4, 104, 1300, 4),
|
(20, 6, 7, 4, 104, 1300, 4),
|
||||||
(25, 8, 9, 5, 105, 1400, 5);
|
(25, 8, 9, 5, 105, 1400, 5);
|
||||||
|
|
||||||
@@ -611,7 +787,7 @@ SET slug = 'tigrex-raid',
|
|||||||
location_id = 3,
|
location_id = 3,
|
||||||
recommended_level = 5,
|
recommended_level = 5,
|
||||||
content_type = 'raid',
|
content_type = 'raid',
|
||||||
party_size = 10,
|
party_size = 18,
|
||||||
completion_item_level = NULL,
|
completion_item_level = NULL,
|
||||||
experience_reward = 275,
|
experience_reward = 275,
|
||||||
description = 'A raid-scale hunt against Tigrex, Rathalos, and Gypceros.'
|
description = 'A raid-scale hunt against Tigrex, Rathalos, and Gypceros.'
|
||||||
@@ -620,29 +796,68 @@ WHERE id = 2;
|
|||||||
INSERT OR IGNORE INTO dungeons
|
INSERT OR IGNORE INTO dungeons
|
||||||
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
|
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
|
||||||
VALUES
|
VALUES
|
||||||
(3, 3, 'tigrex-hunting-ground', 'Tigrex Hunting Ground', 5, 'dungeon', 5, NULL, 205, 'A three-boss hunt featuring Tigrex, Rathalos, and Gypceros.'),
|
(3, 3, 'tigrex-hunting-ground', 'Tigrex Hunting Ground', 5, 'dungeon', 6, NULL, 205, 'A three-boss hunt featuring Tigrex, Rathalos, and Gypceros.'),
|
||||||
(4, 3, 'nargacuga-hunting-ground', 'Nargacuga Hunting Ground', 10, 'dungeon', 5, NULL, 245, 'A three-boss hunt featuring Nargacuga, Azuros, and Diablos.'),
|
(4, 3, 'nargacuga-hunting-ground', 'Nargacuga Hunting Ground', 10, 'dungeon', 6, NULL, 245, 'A three-boss hunt featuring Nargacuga, Azuros, and Diablos.'),
|
||||||
(5, 3, 'nargacuga-raid', 'Nargacuga Raid', 10, 'raid', 10, NULL, 325, 'A raid-scale hunt against Nargacuga, Azuros, and Diablos.'),
|
(5, 3, 'nargacuga-raid', 'Nargacuga Raid', 10, 'raid', 18, NULL, 325, 'A raid-scale hunt against Nargacuga, Azuros, and Diablos.'),
|
||||||
(6, 3, 'barroth-hunting-ground', 'Barroth Hunting Ground', 15, 'dungeon', 5, NULL, 285, 'A three-boss hunt featuring Barroth, Tobi Kadachi, and Monoblos.'),
|
(6, 3, 'barroth-hunting-ground', 'Barroth Hunting Ground', 15, 'dungeon', 6, NULL, 285, 'A three-boss hunt featuring Barroth, Tobi Kadachi, and Monoblos.'),
|
||||||
(7, 3, 'barroth-raid', 'Barroth Raid', 15, 'raid', 10, NULL, 375, 'A raid-scale hunt against Barroth, Tobi Kadachi, and Monoblos.'),
|
(7, 3, 'barroth-raid', 'Barroth Raid', 15, 'raid', 18, NULL, 375, 'A raid-scale hunt against Barroth, Tobi Kadachi, and Monoblos.'),
|
||||||
(8, 3, 'anjanath-hunting-ground', 'Anjanath Hunting Ground', 20, 'dungeon', 5, NULL, 325, 'A three-boss hunt featuring Anjanath, Bazelgeuse, and Odogaron.'),
|
(8, 3, 'anjanath-hunting-ground', 'Anjanath Hunting Ground', 20, 'dungeon', 6, NULL, 325, 'A three-boss hunt featuring Anjanath, Bazelgeuse, and Odogaron.'),
|
||||||
(9, 3, 'anjanath-raid', 'Anjanath Raid', 20, 'raid', 10, NULL, 425, 'A raid-scale hunt against Anjanath, Bazelgeuse, and Odogaron.');
|
(9, 3, 'anjanath-raid', 'Anjanath Raid', 20, 'raid', 18, NULL, 425, 'A raid-scale hunt against Anjanath, Bazelgeuse, and Odogaron.');
|
||||||
|
|
||||||
UPDATE difficulties
|
UPDATE difficulties
|
||||||
SET dropped_item_level = 10,
|
SET dropped_item_level = 10,
|
||||||
unlock_level = 5,
|
unlock_level = 10,
|
||||||
health_multiplier = 1.35,
|
health_multiplier = 1.45,
|
||||||
damage_multiplier = 1.2,
|
damage_multiplier = 1.25,
|
||||||
experience_multiplier = 1.75,
|
experience_multiplier = 2.0,
|
||||||
description = 'Veteran raid difficulty with extra monster-part drops.'
|
description = 'Veteran raid difficulty with extra monster-part drops.'
|
||||||
WHERE id = 101;
|
WHERE id = 101;
|
||||||
|
|
||||||
INSERT OR IGNORE INTO difficulties
|
INSERT OR IGNORE INTO difficulties
|
||||||
(id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description)
|
(id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description)
|
||||||
VALUES
|
VALUES
|
||||||
(103, 'raid-champion', 'Champion Raid', 15, 10, 1.7, 1.45, 2.4, 'Champion raid difficulty with extra monster-part drops.'),
|
(103, 'raid-champion', 'Champion Raid', 15, 15, 1.7, 1.45, 2.4, 'Gear-only raid upgrade tier between Veteran and Mythic.'),
|
||||||
(104, 'raid-mythic', 'Mythic Raid', 20, 15, 2.1, 1.75, 3.2, 'Mythic raid difficulty with extra monster-part drops.'),
|
(104, 'raid-mythic', 'Mythic Raid', 20, 20, 2.25, 1.85, 3.5, 'Mythic raid difficulty with extra monster-part drops.'),
|
||||||
(105, 'raid-ascendant', 'Ascendant Raid', 25, 20, 2.6, 2.1, 4.2, 'Ascendant raid difficulty with extra monster-part drops.');
|
(105, 'raid-ascendant', 'Ascendant Raid', 25, 25, 2.8, 2.25, 4.5, 'Ascendant raid difficulty with extra monster-part drops.');
|
||||||
|
|
||||||
|
UPDATE difficulties
|
||||||
|
SET dropped_item_level = CASE id
|
||||||
|
WHEN 103 THEN 15
|
||||||
|
WHEN 104 THEN 20
|
||||||
|
WHEN 105 THEN 25
|
||||||
|
ELSE dropped_item_level
|
||||||
|
END,
|
||||||
|
unlock_level = CASE id
|
||||||
|
WHEN 103 THEN 15
|
||||||
|
WHEN 104 THEN 20
|
||||||
|
WHEN 105 THEN 25
|
||||||
|
ELSE unlock_level
|
||||||
|
END,
|
||||||
|
health_multiplier = CASE id
|
||||||
|
WHEN 103 THEN 1.7
|
||||||
|
WHEN 104 THEN 2.25
|
||||||
|
WHEN 105 THEN 2.8
|
||||||
|
ELSE health_multiplier
|
||||||
|
END,
|
||||||
|
damage_multiplier = CASE id
|
||||||
|
WHEN 103 THEN 1.45
|
||||||
|
WHEN 104 THEN 1.85
|
||||||
|
WHEN 105 THEN 2.25
|
||||||
|
ELSE damage_multiplier
|
||||||
|
END,
|
||||||
|
experience_multiplier = CASE id
|
||||||
|
WHEN 103 THEN 2.4
|
||||||
|
WHEN 104 THEN 3.5
|
||||||
|
WHEN 105 THEN 4.5
|
||||||
|
ELSE experience_multiplier
|
||||||
|
END,
|
||||||
|
description = CASE id
|
||||||
|
WHEN 103 THEN 'Gear-only raid upgrade tier between Veteran and Mythic.'
|
||||||
|
WHEN 104 THEN 'Mythic raid difficulty with extra monster-part drops.'
|
||||||
|
WHEN 105 THEN 'Ascendant raid difficulty with extra monster-part drops.'
|
||||||
|
ELSE description
|
||||||
|
END
|
||||||
|
WHERE id IN (103, 104, 105);
|
||||||
|
|
||||||
DELETE FROM dungeon_difficulties WHERE dungeon_id = 2 AND difficulty_id <> 101;
|
DELETE FROM dungeon_difficulties WHERE dungeon_id = 2 AND difficulty_id <> 101;
|
||||||
|
|
||||||
@@ -678,9 +893,9 @@ SET slug = CASE id
|
|||||||
END,
|
END,
|
||||||
encounter_type = CASE WHEN id IN (102, 105, 108) THEN 'boss' ELSE 'trash' END,
|
encounter_type = CASE WHEN id IN (102, 105, 108) THEN 'boss' ELSE 'trash' END,
|
||||||
description = CASE id
|
description = CASE id
|
||||||
WHEN 102 THEN 'Tigrex drops monster parts for item level 10 crafting.'
|
WHEN 102 THEN 'Tigrex drops boss coins for item level 10 crafting.'
|
||||||
WHEN 105 THEN 'Rathalos drops monster parts for item level 10 crafting.'
|
WHEN 105 THEN 'Rathalos drops boss coins for item level 10 crafting.'
|
||||||
WHEN 108 THEN 'Gypceros drops monster parts for item level 10 crafting.'
|
WHEN 108 THEN 'Gypceros drops boss coins for item level 10 crafting.'
|
||||||
ELSE 'Hunters clear the raid path.'
|
ELSE 'Hunters clear the raid path.'
|
||||||
END
|
END
|
||||||
WHERE id BETWEEN 100 AND 108;
|
WHERE id BETWEEN 100 AND 108;
|
||||||
@@ -702,7 +917,7 @@ SELECT
|
|||||||
offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8,
|
offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8,
|
||||||
offset.party_damage + generated_bosses.boss_index * 3,
|
offset.party_damage + generated_bosses.boss_index * 3,
|
||||||
CASE offset.encounter_type
|
CASE offset.encounter_type
|
||||||
WHEN 'boss' THEN generated_bosses.boss_name || ' drops monster parts for item level ' || generated_loot_tiers.item_level || ' crafting.'
|
WHEN 'boss' THEN generated_bosses.boss_name || ' drops boss coins for item level ' || generated_loot_tiers.item_level || ' crafting.'
|
||||||
ELSE 'Hunters clear the path before ' || generated_bosses.boss_name || '.'
|
ELSE 'Hunters clear the path before ' || generated_bosses.boss_name || '.'
|
||||||
END
|
END
|
||||||
FROM generated_loot_tiers
|
FROM generated_loot_tiers
|
||||||
@@ -730,7 +945,7 @@ SELECT
|
|||||||
offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8,
|
offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8,
|
||||||
offset.party_damage + generated_bosses.boss_index * 3 + 24,
|
offset.party_damage + generated_bosses.boss_index * 3 + 24,
|
||||||
CASE offset.encounter_type
|
CASE offset.encounter_type
|
||||||
WHEN 'boss' THEN generated_bosses.boss_name || ' drops monster parts for item level ' || generated_loot_tiers.item_level || ' crafting.'
|
WHEN 'boss' THEN generated_bosses.boss_name || ' drops boss coins for item level ' || generated_loot_tiers.item_level || ' crafting.'
|
||||||
ELSE 'Hunters clear the raid path before ' || generated_bosses.boss_name || '.'
|
ELSE 'Hunters clear the raid path before ' || generated_bosses.boss_name || '.'
|
||||||
END
|
END
|
||||||
FROM generated_loot_tiers
|
FROM generated_loot_tiers
|
||||||
@@ -999,6 +1214,19 @@ SET slug = CASE id
|
|||||||
END
|
END
|
||||||
WHERE id BETWEEN 860 AND 871;
|
WHERE id BETWEEN 860 AND 871;
|
||||||
|
|
||||||
|
DELETE FROM dungeon_difficulties;
|
||||||
|
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id) VALUES
|
||||||
|
(1, 1),
|
||||||
|
(1, 2),
|
||||||
|
(1, 4),
|
||||||
|
(1, 5),
|
||||||
|
(3, 2),
|
||||||
|
(6, 4),
|
||||||
|
(8, 5),
|
||||||
|
(2, 101),
|
||||||
|
(7, 104),
|
||||||
|
(9, 105);
|
||||||
|
|
||||||
DELETE FROM crafting_recipe_components WHERE recipe_id BETWEEN 1001 AND 1009;
|
DELETE FROM crafting_recipe_components WHERE recipe_id BETWEEN 1001 AND 1009;
|
||||||
|
|
||||||
INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES
|
INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES
|
||||||
@@ -1011,3 +1239,166 @@ INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
|||||||
(1007, 868, 5), (1007, 869, 3), (1007, 871, 1),
|
(1007, 868, 5), (1007, 869, 3), (1007, 871, 1),
|
||||||
(1008, 868, 5), (1008, 869, 3), (1008, 871, 1),
|
(1008, 868, 5), (1008, 869, 3), (1008, 871, 1),
|
||||||
(1009, 868, 5), (1009, 869, 3), (1009, 871, 1);
|
(1009, 868, 5), (1009, 869, 3), (1009, 871, 1);
|
||||||
|
|
||||||
|
-- Final coin gearing override. Keep this after legacy loot edits.
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET source_dungeon_id = 1,
|
||||||
|
source_encounter_id = 3
|
||||||
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
|
||||||
|
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET source_dungeon_id = 1,
|
||||||
|
source_encounter_id = 12
|
||||||
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
|
||||||
|
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET source_dungeon_id = 1,
|
||||||
|
source_encounter_id = 22
|
||||||
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace'));
|
||||||
|
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET difficulty_id = CASE
|
||||||
|
(SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id)
|
||||||
|
WHEN 1 THEN 1
|
||||||
|
WHEN 5 THEN 1
|
||||||
|
WHEN 10 THEN 2
|
||||||
|
WHEN 15 THEN 2
|
||||||
|
WHEN 20 THEN 4
|
||||||
|
WHEN 25 THEN 5
|
||||||
|
ELSE difficulty_id
|
||||||
|
END
|
||||||
|
WHERE id BETWEEN 901 AND 1409;
|
||||||
|
|
||||||
|
DELETE FROM crafting_recipe_components
|
||||||
|
WHERE recipe_id IN (
|
||||||
|
SELECT crafting_recipes.id
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
|
WHERE items.item_level NOT IN (1, 10, 20, 25)
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM crafting_recipes
|
||||||
|
WHERE item_id IN (
|
||||||
|
SELECT id FROM items WHERE item_level NOT IN (1, 10, 20, 25)
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET rarity = CASE item_level
|
||||||
|
WHEN 1 THEN 'common'
|
||||||
|
WHEN 10 THEN 'uncommon'
|
||||||
|
WHEN 20 THEN 'epic'
|
||||||
|
WHEN 25 THEN 'legendary'
|
||||||
|
ELSE rarity
|
||||||
|
END
|
||||||
|
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET name = (
|
||||||
|
SELECT
|
||||||
|
CASE items.item_level
|
||||||
|
WHEN 1 THEN 'Raw '
|
||||||
|
WHEN 10 THEN 'Green '
|
||||||
|
WHEN 20 THEN 'Purple '
|
||||||
|
WHEN 25 THEN 'Orange '
|
||||||
|
ELSE ''
|
||||||
|
END
|
||||||
|
|| encounters.name || ' '
|
||||||
|
|| CASE items.slot
|
||||||
|
WHEN 'weapon' THEN 'Weapon'
|
||||||
|
WHEN 'helmet' THEN 'Helmet'
|
||||||
|
WHEN 'chest' THEN 'Chest'
|
||||||
|
WHEN 'gloves' THEN 'Gloves'
|
||||||
|
WHEN 'boots' THEN 'Boots'
|
||||||
|
WHEN 'pants' THEN 'Pants'
|
||||||
|
WHEN 'ring' THEN 'Ring'
|
||||||
|
WHEN 'necklace' THEN 'Necklace'
|
||||||
|
WHEN 'trinket' THEN 'Trinket'
|
||||||
|
ELSE items.name
|
||||||
|
END
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||||
|
WHERE crafting_recipes.item_id = items.id
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
description = (
|
||||||
|
SELECT 'Crafted with ' || encounters.name || ' coins.'
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||||
|
WHERE crafting_recipes.item_id = items.id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||||
|
|
||||||
|
DELETE FROM coin_sources;
|
||||||
|
|
||||||
|
INSERT INTO coin_sources
|
||||||
|
SELECT
|
||||||
|
280000 + encounters.id * 100 + difficulties.dropped_item_level,
|
||||||
|
encounters.id,
|
||||||
|
difficulties.id,
|
||||||
|
difficulties.dropped_item_level,
|
||||||
|
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
|
||||||
|
CASE difficulties.dropped_item_level
|
||||||
|
WHEN 1 THEN 'Raw '
|
||||||
|
WHEN 5 THEN 'Honed '
|
||||||
|
WHEN 10 THEN 'Green '
|
||||||
|
WHEN 15 THEN 'Blue '
|
||||||
|
WHEN 20 THEN 'Purple '
|
||||||
|
WHEN 25 THEN 'Orange '
|
||||||
|
ELSE ''
|
||||||
|
END || encounters.name || ' Coin',
|
||||||
|
CASE difficulties.dropped_item_level
|
||||||
|
WHEN 1 THEN 'common'
|
||||||
|
WHEN 5 THEN 'common'
|
||||||
|
WHEN 10 THEN 'uncommon'
|
||||||
|
WHEN 15 THEN 'rare'
|
||||||
|
WHEN 20 THEN 'epic'
|
||||||
|
WHEN 25 THEN 'legendary'
|
||||||
|
ELSE 'common'
|
||||||
|
END,
|
||||||
|
'$',
|
||||||
|
'A boss coin from ' || encounters.name || ' used for item level '
|
||||||
|
|| difficulties.dropped_item_level || ' crafting.'
|
||||||
|
FROM encounters
|
||||||
|
JOIN dungeon_difficulties ON dungeon_difficulties.dungeon_id = encounters.dungeon_id
|
||||||
|
JOIN difficulties ON difficulties.id = dungeon_difficulties.difficulty_id
|
||||||
|
WHERE encounters.encounter_type = 'boss';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO items
|
||||||
|
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
|
||||||
|
SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description
|
||||||
|
FROM coin_sources;
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
slot = 'component',
|
||||||
|
rarity = (SELECT rarity FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
item_level = (SELECT item_level FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
healing_power = 0,
|
||||||
|
max_resource_bonus = 0,
|
||||||
|
glyph = (SELECT glyph FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
description = (SELECT description FROM coin_sources WHERE coin_sources.item_id = items.id)
|
||||||
|
WHERE id IN (SELECT item_id FROM coin_sources);
|
||||||
|
|
||||||
|
DELETE FROM encounter_loot;
|
||||||
|
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
|
||||||
|
SELECT encounter_id, item_id, difficulty_id, 100, 1.0
|
||||||
|
FROM coin_sources;
|
||||||
|
|
||||||
|
DELETE FROM crafting_recipe_components;
|
||||||
|
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
||||||
|
SELECT
|
||||||
|
crafting_recipes.id,
|
||||||
|
coin_sources.item_id,
|
||||||
|
items.item_level
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
|
JOIN coin_sources
|
||||||
|
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
|
||||||
|
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Gearing System
|
||||||
|
|
||||||
|
## Current Rule
|
||||||
|
|
||||||
|
The game uses fewer playable content tiers and more gear upgrade steps.
|
||||||
|
|
||||||
|
Content tiers:
|
||||||
|
|
||||||
|
| Content Tier | Unlock Level | Purpose |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| iLvl 1 | 1 | First real gear set |
|
||||||
|
| iLvl 10 | 10 | Midgame jump |
|
||||||
|
| iLvl 20 | 20 | Endgame jump |
|
||||||
|
| iLvl 25 | 25 | Hard endgame |
|
||||||
|
|
||||||
|
Gear tiers:
|
||||||
|
|
||||||
|
| Gear Tier | How It Is Used |
|
||||||
|
| ---: | --- |
|
||||||
|
| 1 | Crafted from iLvl 1 content |
|
||||||
|
| 5 | Upgrade tier from iLvl 1 content coins |
|
||||||
|
| 10 | Crafted/upgraded from iLvl 10 content coins |
|
||||||
|
| 15 | Gear-only upgrade tier from iLvl 10 content coins |
|
||||||
|
| 20 | Crafted/upgraded from iLvl 20 content coins |
|
||||||
|
| 25 | Crafted/upgraded from iLvl 25 content coins |
|
||||||
|
|
||||||
|
This keeps the dungeon/raid picker simple while still giving players steady gear goals.
|
||||||
|
|
||||||
|
## New Characters
|
||||||
|
|
||||||
|
New characters start with no gear equipped.
|
||||||
|
|
||||||
|
The first goal is to clear iLvl 1 content, earn raw boss coins, and craft the first iLvl 1 set. Starter gear exists as craftable gear, not automatic inventory.
|
||||||
|
|
||||||
|
## Coin Tiers
|
||||||
|
|
||||||
|
Coins are component items. Each coin is tied to a boss and a content tier.
|
||||||
|
|
||||||
|
| Content Tier | Coin Prefix | Rarity Key | Example |
|
||||||
|
| ---: | --- | --- | --- |
|
||||||
|
| 1 | Raw | common | Raw Bulldrome Coin |
|
||||||
|
| 10 | Green | uncommon | Green Bulldrome Coin |
|
||||||
|
| 20 | Purple | epic | Purple Bulldrome Coin |
|
||||||
|
| 25 | Orange | legendary | Orange Bulldrome Coin |
|
||||||
|
|
||||||
|
iLvl 5 and iLvl 15 gear do not have their own playable content tier. They use coins from the previous playable tier:
|
||||||
|
|
||||||
|
| Gear Tier | Coin Tier Used |
|
||||||
|
| ---: | ---: |
|
||||||
|
| 1 | 1 |
|
||||||
|
| 5 | 1 |
|
||||||
|
| 10 | 10 |
|
||||||
|
| 15 | 10 |
|
||||||
|
| 20 | 20 |
|
||||||
|
| 25 | 25 |
|
||||||
|
|
||||||
|
## Boss Loot
|
||||||
|
|
||||||
|
Each boss drops one boss coin for the selected content tier.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Bulldrome at iLvl 1 drops Raw Bulldrome Coins.
|
||||||
|
- Tigrex at iLvl 10 drops Green Tigrex Coins.
|
||||||
|
- Barroth at iLvl 20 drops Purple Barroth Coins.
|
||||||
|
- Anjanath at iLvl 25 drops Orange Anjanath Coins.
|
||||||
|
|
||||||
|
## Crafting And Upgrades
|
||||||
|
|
||||||
|
The first gear item in a boss/item line can be crafted directly. Higher versions should be reached through Upgrade.
|
||||||
|
|
||||||
|
Current rule:
|
||||||
|
|
||||||
|
- Craft iLvl 1 boss gear directly.
|
||||||
|
- Upgrade iLvl 1 -> 5 with iLvl 1 coins.
|
||||||
|
- Craft or upgrade iLvl 10 gear from iLvl 10 content.
|
||||||
|
- Upgrade iLvl 10 -> 15 with iLvl 10 coins.
|
||||||
|
- Craft or upgrade iLvl 20 gear from iLvl 20 content.
|
||||||
|
- Craft or upgrade iLvl 25 gear from iLvl 25 content.
|
||||||
|
|
||||||
|
Upgrade consumes the old item and awards the upgraded item. This avoids duplicate clutter and keeps item identity clear.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
| Upgrade | Cost Source |
|
||||||
|
| --- | --- |
|
||||||
|
| Raw Bulldrome Helmet iLvl 1 -> Honed Bulldrome Helmet iLvl 5 | Raw Bulldrome Coins |
|
||||||
|
| Green Tigrex Helmet iLvl 10 -> Blue Tigrex Helmet iLvl 15 | Green Tigrex Coins |
|
||||||
|
| Purple Bulldrome Helmet iLvl 20 -> Orange Bulldrome Helmet iLvl 25 | Orange Bulldrome Coins |
|
||||||
|
|
||||||
|
## UI Behavior
|
||||||
|
|
||||||
|
The dungeon and raid picker only shows playable content tiers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
iLvl 1 -> iLvl 10 -> iLvl 20 -> iLvl 25
|
||||||
|
```
|
||||||
|
|
||||||
|
The equipment screen still shows gear recipes for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
iLvl 1 -> iLvl 5 -> iLvl 10 -> iLvl 15 -> iLvl 20 -> iLvl 25
|
||||||
|
```
|
||||||
|
|
||||||
|
Direct crafting is blocked for recipes that have a lower item-level version in the same boss/item line. Use the selected item's Upgrade button for those.
|
||||||
|
|
||||||
|
## Roguelike Loot
|
||||||
|
|
||||||
|
Roguelike gear should follow the same tier brackets.
|
||||||
|
|
||||||
|
Recommended mapping:
|
||||||
|
|
||||||
|
| Stage Band | Coin Tier |
|
||||||
|
| --- | ---: |
|
||||||
|
| 1-4 | 1 |
|
||||||
|
| 5-9 | 10 |
|
||||||
|
| 10-14 | 10 |
|
||||||
|
| 15-19 | 20 |
|
||||||
|
| 20+ | 25 |
|
||||||
|
|
||||||
|
Boss-based coins are still preferred. A roguelike boss should award coins for its actual boss template when possible.
|
||||||
|
|
||||||
|
## Data Notes
|
||||||
|
|
||||||
|
Authoritative gearing data lives in SQLite seed data:
|
||||||
|
|
||||||
|
- `db/seed.sql`
|
||||||
|
- `src/offline-starter-profile.json`
|
||||||
|
|
||||||
|
Run this after changing seed data:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run db:init
|
||||||
|
npm run offline:export
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run build` already runs `offline:export`, so a production build also refreshes the offline starter profile.
|
||||||
|
|
||||||
|
TrueNAS keeps its own persistent `data/game.db`. Pushing code does not merge or replace that database. The TrueNAS app applies seed/schema changes when the container starts and runs `npm run db:init`.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Push Updates
|
||||||
|
|
||||||
|
Use this when pushing code from the Mac to `git.whoagland.com`, then updating the TrueNAS-hosted server.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Git deploys code only.
|
||||||
|
- TrueNAS save data lives in `/mnt/usbssds/apps/iwanttoheal/data/game.db`.
|
||||||
|
- Do not commit, copy, or replace `data/game.db`.
|
||||||
|
- Do not run character reset commands unless you intentionally want a wipe.
|
||||||
|
- Restarting the TrueNAS app runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start`.
|
||||||
|
|
||||||
|
## Step 1: Build Web Locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This validates TypeScript, builds the browser app, and refreshes `src/offline-starter-profile.json`.
|
||||||
|
|
||||||
|
## Step 2: Optional Android APK
|
||||||
|
|
||||||
|
Only run this when building a new APK.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
|
||||||
|
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
VERSION="1.0.27"
|
||||||
|
|
||||||
|
CURRENT_CODE=$(grep -E 'versionCode [0-9]+' android/app/build.gradle | awk '{print $2}')
|
||||||
|
NEXT_CODE=$((CURRENT_CODE + 1))
|
||||||
|
|
||||||
|
perl -0pi -e "s/versionCode\s+\d+/versionCode $NEXT_CODE/" android/app/build.gradle
|
||||||
|
perl -0pi -e "s/versionName\s+\"[^\"]+\"/versionName \"$VERSION\"/" android/app/build.gradle
|
||||||
|
|
||||||
|
VITE_API_BASE_URL="https://iwanttoheal.phenomrom.com" npm run android:sync
|
||||||
|
|
||||||
|
cd android
|
||||||
|
./gradlew clean assembleDebug
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
cp android/app/build/outputs/apk/debug/app-debug.apk "IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
ls -lh "IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Commit And Push Code
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
|
||||||
|
git add .
|
||||||
|
git commit -m "Update game 1.0.27"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Check before committing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: source/doc/build-output changes only. Do not stage `data/game.db`.
|
||||||
|
|
||||||
|
## Step 4: Optional Gitea Release For APK
|
||||||
|
|
||||||
|
Only run this when Step 2 created a new APK.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
|
||||||
|
export GITEA_URL="https://git.whoagland.com"
|
||||||
|
export GITEA_OWNER="phenom"
|
||||||
|
export GITEA_REPO="i-want-to-heal"
|
||||||
|
export GITEA_TOKEN="PASTE_TOKEN_HERE"
|
||||||
|
|
||||||
|
VERSION="1.0.26"
|
||||||
|
APK="IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
|
||||||
|
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"v$VERSION\",\"target_commitish\":\"main\",\"name\":\"v$VERSION\",\"body\":\"I Want to Heal Android build v$VERSION\",\"draft\":false,\"prerelease\":false}")
|
||||||
|
|
||||||
|
RELEASE_ID=$(python3 -c 'import sys,json; data=json.load(sys.stdin); print(data.get("id") or data)' <<< "$RELEASE_JSON")
|
||||||
|
|
||||||
|
curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$APK" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-F "attachment=@$APK"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Update TrueNAS
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Before restarting, make a DB backup:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
|
||||||
|
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the `iwanttoheal` app in the TrueNAS Apps UI.
|
||||||
|
|
||||||
|
## What Happens On Restart
|
||||||
|
|
||||||
|
The app command runs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci && npm run db:init && npm run build && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- dependency changes apply
|
||||||
|
- schema changes apply
|
||||||
|
- seed/static-content updates apply
|
||||||
|
- browser files rebuild
|
||||||
|
- existing accounts and characters stay in `data/game.db`
|
||||||
|
|
||||||
|
`npm run db:init` should not wipe saves. Character wipes are separate manual reset operations.
|
||||||
|
|
||||||
|
## Resetting TrueNAS Characters
|
||||||
|
|
||||||
|
Only run a reset when intentionally starting everyone over.
|
||||||
|
|
||||||
|
Resetting the Mac database does not reset TrueNAS. TrueNAS has its own persistent DB at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/usbssds/apps/iwanttoheal/data/game.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Back it up first, then run the reset command or reset SQL on TrueNAS.
|
||||||
|
|
||||||
|
## If Something Looks Wrong
|
||||||
|
|
||||||
|
Check the mounted DB path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls -lh /mnt/usbssds/apps/iwanttoheal/data/game.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the latest code:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git log --oneline -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the app API:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://127.0.0.1:4173/api/auth/session
|
||||||
|
```
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
|
||||||
|
<text x="1075" y="88" fill="#e5b95f" font-family="monospace" font-size="18">B Back</text>
|
||||||
|
|
||||||
|
<text x="78" y="158" fill="#e5b95f" font-family="monospace" font-size="18">Pick Dungeon</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="178" width="335" height="128" fill="#24262f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<rect x="94" y="194" width="72" height="72" fill="#481e29" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="116" y="241" fill="#ef6574" font-family="monospace" font-size="28" font-weight="700">AH</text>
|
||||||
|
<text x="184" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="184" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 1 • 6 Players</text>
|
||||||
|
<text x="184" y="276" fill="#e5b95f" font-family="monospace" font-size="16">Selected</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="436" y="178" width="335" height="128" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="452" y="194" width="72" height="72" fill="#2a263f" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="474" y="241" fill="#8ca9ff" font-family="monospace" font-size="28" font-weight="700">SC</text>
|
||||||
|
<text x="542" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt</text>
|
||||||
|
<text x="542" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 5 • 6 Players</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="794" y="178" width="335" height="128" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="810" y="194" width="72" height="72" fill="#223226" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="832" y="241" fill="#70d990" font-family="monospace" font-size="28" font-weight="700">GM</text>
|
||||||
|
<text x="900" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Grove Maw</text>
|
||||||
|
<text x="900" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 10 • 6 Players</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="356" fill="#e5b95f" font-family="monospace" font-size="18">Pick Part</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="376" width="282" height="82" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="112" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 1</text>
|
||||||
|
<text x="250" y="426" fill="#8f90a0" font-family="monospace" font-size="16">3 fights</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="382" y="376" width="282" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="416" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 2</text>
|
||||||
|
<text x="554" y="426" fill="#8f90a0" font-family="monospace" font-size="16">Locked</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="686" y="376" width="282" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="720" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 3</text>
|
||||||
|
<text x="858" y="426" fill="#8f90a0" font-family="monospace" font-size="16">Locked</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="508" fill="#e5b95f" font-family="monospace" font-size="18">Pick Difficulty</text>
|
||||||
|
<rect x="78" y="528" width="240" height="64" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="116" y="568" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Normal</text>
|
||||||
|
<rect x="342" y="528" width="240" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="380" y="568" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Heroic</text>
|
||||||
|
<rect x="606" y="528" width="240" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="644" y="568" fill="#777988" font-family="monospace" font-size="20" font-weight="700">Mythic L10</text>
|
||||||
|
|
||||||
|
<rect x="914" y="508" width="238" height="86" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="982" y="559" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Start</text>
|
||||||
|
<text x="78" y="638" fill="#aaa9b7" font-family="monospace" font-size="17">Idea A: All choices are button grids. D-pad works everywhere. No native dropdown.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
|
||||||
|
<text x="1014" y="88" fill="#e5b95f" font-family="monospace" font-size="18">LB/RB Change</text>
|
||||||
|
|
||||||
|
<rect x="78" y="150" width="760" height="398" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="112" y="184" width="164" height="164" fill="#481e29" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="151" y="280" fill="#ef6574" font-family="monospace" font-size="48" font-weight="700">AH</text>
|
||||||
|
<text x="306" y="202" fill="#8f90a0" font-family="monospace" font-size="18">CURRENT DUNGEON</text>
|
||||||
|
<text x="306" y="248" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="306" y="286" fill="#aaa9b7" font-family="monospace" font-size="18">Guide a six-player party through burning halls.</text>
|
||||||
|
<text x="306" y="324" fill="#e5b95f" font-family="monospace" font-size="18">Level 1 • 6 Players • 100 XP</text>
|
||||||
|
<rect x="112" y="384" width="690" height="104" fill="#15161c" stroke="#33343d" stroke-width="3"/>
|
||||||
|
<text x="138" y="425" fill="#f2f0dc" font-family="monospace" font-size="19">Ashen Halls</text>
|
||||||
|
<text x="356" y="425" fill="#8f90a0" font-family="monospace" font-size="19">Sunken Crypt</text>
|
||||||
|
<text x="608" y="425" fill="#8f90a0" font-family="monospace" font-size="19">Grove Maw</text>
|
||||||
|
<rect x="128" y="448" width="146" height="8" fill="#e5b95f"/>
|
||||||
|
|
||||||
|
<rect x="874" y="150" width="278" height="398" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="906" y="194" fill="#e5b95f" font-family="monospace" font-size="18">Setup</text>
|
||||||
|
<text x="906" y="232" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Part</text>
|
||||||
|
<rect x="906" y="252" width="68" height="54" fill="#29291f" stroke="#e5b95f" stroke-width="4"/>
|
||||||
|
<text x="932" y="286" fill="#f2f0dc" font-family="monospace" font-size="20">1</text>
|
||||||
|
<rect x="990" y="252" width="68" height="54" fill="#20222a" stroke="#41404a" stroke-width="3"/>
|
||||||
|
<text x="1016" y="286" fill="#8f90a0" font-family="monospace" font-size="20">2</text>
|
||||||
|
<rect x="1074" y="252" width="68" height="54" fill="#20222a" stroke="#41404a" stroke-width="3"/>
|
||||||
|
<text x="1100" y="286" fill="#8f90a0" font-family="monospace" font-size="20">3</text>
|
||||||
|
|
||||||
|
<text x="906" y="350" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Difficulty</text>
|
||||||
|
<rect x="906" y="372" width="236" height="52" fill="#29291f" stroke="#e5b95f" stroke-width="4"/>
|
||||||
|
<text x="938" y="405" fill="#f2f0dc" font-family="monospace" font-size="18">Normal</text>
|
||||||
|
<rect x="906" y="436" width="236" height="52" fill="#20222a" stroke="#41404a" stroke-width="3"/>
|
||||||
|
<text x="938" y="469" fill="#aaa9b7" font-family="monospace" font-size="18">Heroic</text>
|
||||||
|
|
||||||
|
<rect x="78" y="580" width="238" height="70" fill="#15161c" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="120" y="624" fill="#e5b95f" font-family="monospace" font-size="22">Prev</text>
|
||||||
|
<rect x="342" y="580" width="238" height="70" fill="#15161c" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="392" y="624" fill="#e5b95f" font-family="monospace" font-size="22">Next</text>
|
||||||
|
<rect x="874" y="580" width="278" height="70" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="963" y="624" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Start</text>
|
||||||
|
<text x="78" y="684" fill="#aaa9b7" font-family="monospace" font-size="17">Idea B: One big focused dungeon. Shoulder buttons or side buttons cycle dungeon.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Mission Board</text>
|
||||||
|
<text x="1046" y="88" fill="#e5b95f" font-family="monospace" font-size="18">A Start</text>
|
||||||
|
|
||||||
|
<rect x="78" y="150" width="500" height="474" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="112" y="194" fill="#e5b95f" font-family="monospace" font-size="18">Available Runs</text>
|
||||||
|
<g>
|
||||||
|
<rect x="112" y="222" width="432" height="82" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="136" y="255" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 1</text>
|
||||||
|
<text x="136" y="283" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • iLvl 1 • 100 XP</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="112" y="318" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="136" y="351" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 1</text>
|
||||||
|
<text x="136" y="379" fill="#aaa9b7" font-family="monospace" font-size="16">Heroic • iLvl 5 • 140 XP</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="112" y="414" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="136" y="447" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt • Part 1</text>
|
||||||
|
<text x="136" y="475" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • Locked Level 5</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="112" y="510" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="136" y="543" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 2</text>
|
||||||
|
<text x="136" y="571" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • Locked until Part 1 clear</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect x="616" y="150" width="536" height="474" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="650" y="184" width="130" height="130" fill="#481e29" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="684" y="262" fill="#ef6574" font-family="monospace" font-size="42" font-weight="700">AH</text>
|
||||||
|
<text x="812" y="194" fill="#8f90a0" font-family="monospace" font-size="18">SELECTED RUN</text>
|
||||||
|
<text x="812" y="238" fill="#f2f0dc" font-family="monospace" font-size="30" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="812" y="278" fill="#e5b95f" font-family="monospace" font-size="20">Part 1 • Normal</text>
|
||||||
|
<text x="650" y="358" fill="#aaa9b7" font-family="monospace" font-size="17">Fastest path for controller play. One list item is the exact run.</text>
|
||||||
|
<text x="650" y="394" fill="#aaa9b7" font-family="monospace" font-size="17">No separate dungeon, phase, or difficulty controls.</text>
|
||||||
|
|
||||||
|
<rect x="650" y="440" width="470" height="64" fill="#15161c" stroke="#33343d" stroke-width="3"/>
|
||||||
|
<text x="680" y="480" fill="#f2f0dc" font-family="monospace" font-size="18">Health 1.00x Damage 1.00x XP 1.0x</text>
|
||||||
|
<rect x="650" y="532" width="220" height="62" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="716" y="570" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Start</text>
|
||||||
|
<rect x="900" y="532" width="220" height="62" fill="#15161c" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="955" y="570" fill="#e5b95f" font-family="monospace" font-size="22">Loot</text>
|
||||||
|
|
||||||
|
<text x="78" y="684" fill="#aaa9b7" font-family="monospace" font-size="17">Idea C: Flat mission list. Most controller-friendly, least setup flexibility on one screen.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,72 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
|
||||||
|
<text x="958" y="88" fill="#e5b95f" font-family="monospace" font-size="18">B Back • A Select</text>
|
||||||
|
|
||||||
|
<text x="78" y="158" fill="#e5b95f" font-family="monospace" font-size="18">1. Pick Item Level</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="125" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">5</text>
|
||||||
|
<text x="108" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Initiate</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="268" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="310" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">10</text>
|
||||||
|
<text x="300" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Veteran</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="458" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="500" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">15</text>
|
||||||
|
<text x="488" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Champion</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="648" y="178" width="170" height="72" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="690" y="222" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">20</text>
|
||||||
|
<text x="690" y="240" fill="#e5b95f" font-family="monospace" font-size="12">Mythic</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="838" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="880" y="222" fill="#777988" font-family="monospace" font-size="22">25</text>
|
||||||
|
<text x="864" y="240" fill="#777988" font-family="monospace" font-size="12">Level 20</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="304" fill="#e5b95f" font-family="monospace" font-size="18">2. Pick Run</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="324" width="335" height="154" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<rect x="96" y="346" width="64" height="64" fill="#481e29" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="114" y="387" fill="#ef6574" font-family="monospace" font-size="24" font-weight="700">AH</text>
|
||||||
|
<text x="178" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="178" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
|
||||||
|
<text x="96" y="446" fill="#e5b95f" font-family="monospace" font-size="16">Part 1 unlocked</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="436" y="324" width="335" height="154" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="454" y="346" width="64" height="64" fill="#2a263f" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="472" y="387" fill="#8ca9ff" font-family="monospace" font-size="24" font-weight="700">SC</text>
|
||||||
|
<text x="536" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt</text>
|
||||||
|
<text x="536" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
|
||||||
|
<text x="454" y="446" fill="#aaa9b7" font-family="monospace" font-size="16">Part 1 unlocked</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="794" y="324" width="335" height="154" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="812" y="346" width="64" height="64" fill="#223226" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="830" y="387" fill="#70d990" font-family="monospace" font-size="24" font-weight="700">GM</text>
|
||||||
|
<text x="894" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Grove Maw</text>
|
||||||
|
<text x="894" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
|
||||||
|
<text x="812" y="446" fill="#777988" font-family="monospace" font-size="16">Locked dungeon</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="532" fill="#e5b95f" font-family="monospace" font-size="18">3. Pick Part</text>
|
||||||
|
<rect x="78" y="552" width="250" height="64" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="124" y="592" fill="#f2f0dc" font-family="monospace" font-size="20">Part 1</text>
|
||||||
|
<rect x="352" y="552" width="250" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="398" y="592" fill="#aaa9b7" font-family="monospace" font-size="20">Part 2</text>
|
||||||
|
<rect x="626" y="552" width="250" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="672" y="592" fill="#aaa9b7" font-family="monospace" font-size="20">Part 3</text>
|
||||||
|
<rect x="926" y="552" width="226" height="64" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="991" y="592" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Start</text>
|
||||||
|
|
||||||
|
<text x="78" y="662" fill="#aaa9b7" font-family="monospace" font-size="17">Idea D: submenu flow. Pick item level first, then only compatible dungeon cards appear.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
+5
-2
@@ -6,16 +6,19 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "npm run db:init",
|
"predev": "npm run db:init",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "npx tsc -b --noEmit && npx vite build && node scripts/generate-service-worker.mjs",
|
"build": "npm run offline:export && npx tsc -b --noEmit && npx vite build && node scripts/generate-service-worker.mjs",
|
||||||
"android:sync": "npm run build && cap sync android",
|
"android:sync": "npm run build && cap sync android",
|
||||||
|
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:sync",
|
||||||
"android:open": "cap open android",
|
"android:open": "cap open android",
|
||||||
"android:apk": "npm run android:sync && cd android && ./gradlew assembleDebug",
|
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
|
||||||
|
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
|
||||||
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
|
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
|
||||||
"db:backup": "node scripts/backup-db.mjs",
|
"db:backup": "node scripts/backup-db.mjs",
|
||||||
"db:init": "node scripts/init-db.mjs",
|
"db:init": "node scripts/init-db.mjs",
|
||||||
"offline:export": "node scripts/export-offline-profile.mjs",
|
"offline:export": "node scripts/export-offline-profile.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"admin:start": "node server/admin.mjs",
|
"admin:start": "node server/admin.mjs",
|
||||||
|
"auth:start": "node server/auth.mjs",
|
||||||
"start": "node server/production.mjs",
|
"start": "node server/production.mjs",
|
||||||
"prepreview": "npm run db:init",
|
"prepreview": "npm run db:init",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createServer } from 'node:http'
|
||||||
|
import { handleAuthApiRequest } from './game-api.mjs'
|
||||||
|
|
||||||
|
process.env.NODE_ENV = process.env.NODE_ENV ?? 'production'
|
||||||
|
process.env.CORS_ORIGINS = process.env.CORS_ORIGINS
|
||||||
|
?? process.env.AUTH_CORS_ORIGINS
|
||||||
|
?? '*'
|
||||||
|
|
||||||
|
const host = process.env.AUTH_HOST ?? process.env.HOST ?? '127.0.0.1'
|
||||||
|
const port = Number(process.env.AUTH_PORT ?? process.env.PORT ?? 4174)
|
||||||
|
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
handleAuthApiRequest(request, response)
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
console.log(`I want to Heal auth listening on http://${host}:${port}`)
|
||||||
|
})
|
||||||
+675
-68
@@ -33,6 +33,31 @@ function sendJson(response, status, body, headers = {}) {
|
|||||||
response.end(JSON.stringify(body))
|
response.end(JSON.stringify(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configuredCorsOrigins() {
|
||||||
|
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCorsHeaders(response, request) {
|
||||||
|
const origin = request.headers.origin
|
||||||
|
if (typeof origin !== 'string') return
|
||||||
|
const allowedOrigins = configuredCorsOrigins()
|
||||||
|
if (!allowedOrigins.includes('*') && !allowedOrigins.includes(origin)) return
|
||||||
|
response.setHeader('Access-Control-Allow-Origin', origin)
|
||||||
|
response.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,OPTIONS')
|
||||||
|
response.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
|
||||||
|
response.setHeader('Access-Control-Max-Age', '86400')
|
||||||
|
response.setHeader('Vary', 'Origin')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCorsPreflight(request, response) {
|
||||||
|
setCorsHeaders(response, request)
|
||||||
|
response.statusCode = 204
|
||||||
|
response.end()
|
||||||
|
}
|
||||||
|
|
||||||
async function readJson(request, maxSize = 16 * 1024) {
|
async function readJson(request, maxSize = 16 * 1024) {
|
||||||
const chunks = []
|
const chunks = []
|
||||||
let size = 0
|
let size = 0
|
||||||
@@ -260,6 +285,17 @@ function parseCookies(request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bearerToken(request) {
|
||||||
|
const authorization = request.headers.authorization
|
||||||
|
if (typeof authorization !== 'string') return ''
|
||||||
|
const match = authorization.match(/^Bearer\s+(.+)$/i)
|
||||||
|
return match ? match[1].trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSessionToken(request) {
|
||||||
|
return bearerToken(request) || parseCookies(request)[sessionCookieName] || ''
|
||||||
|
}
|
||||||
|
|
||||||
function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) {
|
function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) {
|
||||||
const secure = request.headers['x-forwarded-proto'] === 'https'
|
const secure = request.headers['x-forwarded-proto'] === 'https'
|
||||||
|| Boolean(request.socket.encrypted)
|
|| Boolean(request.socket.encrypted)
|
||||||
@@ -284,7 +320,7 @@ function createSession(database, accountId, ip, activeCharacterId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentSession(database, request) {
|
function currentSession(database, request) {
|
||||||
const token = parseCookies(request)[sessionCookieName]
|
const token = requestSessionToken(request)
|
||||||
if (!token) return null
|
if (!token) return null
|
||||||
return database.prepare(`
|
return database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -327,13 +363,6 @@ function initializeCharacter(database, accountId, characterName, classId) {
|
|||||||
;[...starterSpells, null].slice(0, 6).forEach((spellId, index) => {
|
;[...starterSpells, null].slice(0, 6).forEach((spellId, index) => {
|
||||||
insertSlot.run(characterId, index + 1, spellId)
|
insertSlot.run(characterId, index + 1, spellId)
|
||||||
})
|
})
|
||||||
const insertItem = database.prepare(`
|
|
||||||
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
|
||||||
VALUES (?, ?, 1, ?)
|
|
||||||
`)
|
|
||||||
for (let itemId = 100; itemId <= 107; itemId += 1) {
|
|
||||||
insertItem.run(characterId, itemId, itemId === 107 ? 0 : 1)
|
|
||||||
}
|
|
||||||
return characterId
|
return characterId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,6 +883,337 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportCharacterData(database, characterId, classId) {
|
||||||
|
const character = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
level,
|
||||||
|
experience,
|
||||||
|
talent_points AS talentPoints
|
||||||
|
FROM characters
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(characterId)
|
||||||
|
const slots = database.prepare(`
|
||||||
|
SELECT slot_number AS slotNumber, spell_id AS spellId
|
||||||
|
FROM character_ability_slots
|
||||||
|
WHERE character_id = ?
|
||||||
|
ORDER BY slot_number
|
||||||
|
`).all(characterId)
|
||||||
|
const talents = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
talents.id,
|
||||||
|
COALESCE(character_talents.rank, 0) AS rank
|
||||||
|
FROM talents
|
||||||
|
LEFT JOIN character_talents
|
||||||
|
ON character_talents.talent_id = talents.id
|
||||||
|
AND character_talents.character_id = ?
|
||||||
|
WHERE talents.class_id = ?
|
||||||
|
ORDER BY talents.id
|
||||||
|
`).all(characterId, classId)
|
||||||
|
const inventory = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
items.id,
|
||||||
|
items.slug,
|
||||||
|
items.name,
|
||||||
|
items.slot,
|
||||||
|
items.rarity,
|
||||||
|
items.item_level AS itemLevel,
|
||||||
|
items.healing_power AS healingPower,
|
||||||
|
items.max_resource_bonus AS maxResourceBonus,
|
||||||
|
items.glyph,
|
||||||
|
items.description,
|
||||||
|
item_sets.id AS setId,
|
||||||
|
item_sets.slug AS setSlug,
|
||||||
|
item_sets.name AS setName,
|
||||||
|
character_inventory.quantity,
|
||||||
|
character_inventory.equipped
|
||||||
|
FROM character_inventory
|
||||||
|
JOIN items ON items.id = character_inventory.item_id
|
||||||
|
LEFT JOIN item_set_items ON item_set_items.item_id = items.id
|
||||||
|
LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id
|
||||||
|
WHERE character_inventory.character_id = ?
|
||||||
|
ORDER BY items.slot, items.item_level DESC, items.id
|
||||||
|
`).all(characterId).map((item) => ({ ...item, equipped: Boolean(item.equipped) }))
|
||||||
|
const talentRanks = {}
|
||||||
|
for (const talent of talents) {
|
||||||
|
if (talent.rank > 0) {
|
||||||
|
talentRanks[String(talent.id)] = talent.rank
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
level: character.level,
|
||||||
|
experience: character.experience,
|
||||||
|
talentPoints: character.talentPoints,
|
||||||
|
abilitySlots: Array.from({ length: 6 }, (_, index) => {
|
||||||
|
const slot = slots.find((candidate) => candidate.slotNumber === index + 1)
|
||||||
|
return slot?.spellId ?? null
|
||||||
|
}),
|
||||||
|
talentRanks,
|
||||||
|
inventory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSyncSave(database, accountId, activeCharacterId) {
|
||||||
|
const account = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
completed_dungeon_parts AS completedDungeonParts,
|
||||||
|
completed_raid_phases AS completedRaidPhases
|
||||||
|
FROM accounts
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(accountId)
|
||||||
|
const characters = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
class_id AS classId,
|
||||||
|
name
|
||||||
|
FROM characters
|
||||||
|
WHERE account_id = ?
|
||||||
|
ORDER BY class_id
|
||||||
|
`).all(accountId)
|
||||||
|
const activeClassId = characters.find((candidate) => candidate.id === activeCharacterId)?.classId
|
||||||
|
?? characters[0]?.classId
|
||||||
|
?? 1
|
||||||
|
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
|
||||||
|
?? characters[0]?.name
|
||||||
|
?? 'Mira'
|
||||||
|
return {
|
||||||
|
version: 3,
|
||||||
|
characterName,
|
||||||
|
activeClassId,
|
||||||
|
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
||||||
|
completedRaidPhases: account?.completedRaidPhases ?? 0,
|
||||||
|
characters: Object.fromEntries(
|
||||||
|
characters.map((character) => [
|
||||||
|
character.classId,
|
||||||
|
exportCharacterData(database, character.id, character.classId),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
lootRolls: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInteger(value, fallback, min, max) {
|
||||||
|
const numeric = Number(value)
|
||||||
|
if (!Number.isInteger(numeric)) return fallback
|
||||||
|
return Math.min(max, Math.max(min, numeric))
|
||||||
|
}
|
||||||
|
|
||||||
|
function importSyncSave(database, accountId, activeCharacterId, payload) {
|
||||||
|
const save = payload?.save
|
||||||
|
if (
|
||||||
|
!save
|
||||||
|
|| typeof save !== 'object'
|
||||||
|
|| Number(save.version) !== 3
|
||||||
|
|| typeof save.characterName !== 'string'
|
||||||
|
|| !save.characters
|
||||||
|
|| typeof save.characters !== 'object'
|
||||||
|
) {
|
||||||
|
throw new Error('The local save snapshot is invalid.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLevel = Number(
|
||||||
|
database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
|
||||||
|
)
|
||||||
|
const maxTalentPoints = Number(
|
||||||
|
database.prepare("SELECT value FROM game_settings WHERE key = 'max_talent_points'").get()?.value ?? 25,
|
||||||
|
)
|
||||||
|
const maxExperience = database.prepare(`
|
||||||
|
SELECT experience_required AS experienceRequired
|
||||||
|
FROM level_progression
|
||||||
|
WHERE level = ?
|
||||||
|
`).get(maxLevel).experienceRequired
|
||||||
|
const classIds = database.prepare('SELECT id FROM classes ORDER BY id').all().map((row) => row.id)
|
||||||
|
const existingCharacters = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
class_id AS classId,
|
||||||
|
name
|
||||||
|
FROM characters
|
||||||
|
WHERE account_id = ?
|
||||||
|
ORDER BY class_id
|
||||||
|
`).all(accountId)
|
||||||
|
if (existingCharacters.length === 0) {
|
||||||
|
throw new Error('No character found for this account.')
|
||||||
|
}
|
||||||
|
const baseCharacterName = existingCharacters.find((candidate) => candidate.id === activeCharacterId)?.name
|
||||||
|
?? existingCharacters[0].name
|
||||||
|
const characterName = normalizeCharacterName(save.characterName, baseCharacterName)
|
||||||
|
const itemRows = database.prepare(`
|
||||||
|
SELECT id, slot
|
||||||
|
FROM items
|
||||||
|
`).all()
|
||||||
|
const itemSlots = new Map(itemRows.map((item) => [item.id, item.slot]))
|
||||||
|
const spellIdsByClass = new Map(
|
||||||
|
classIds.map((classId) => [
|
||||||
|
classId,
|
||||||
|
new Set(
|
||||||
|
database.prepare(`
|
||||||
|
SELECT id
|
||||||
|
FROM spells
|
||||||
|
WHERE class_id = ?
|
||||||
|
`).all(classId).map((spell) => spell.id),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const talentRowsByClass = new Map(
|
||||||
|
classIds.map((classId) => [
|
||||||
|
classId,
|
||||||
|
database.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
max_rank AS maxRank
|
||||||
|
FROM talents
|
||||||
|
WHERE class_id = ?
|
||||||
|
`).all(classId),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const charactersByClass = new Map(existingCharacters.map((character) => [character.classId, character]))
|
||||||
|
|
||||||
|
database.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
for (const classId of classIds) {
|
||||||
|
if (!charactersByClass.has(classId)) {
|
||||||
|
const characterId = initializeCharacter(database, accountId, characterName, classId)
|
||||||
|
charactersByClass.set(classId, { id: characterId, classId, name: characterName })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE accounts
|
||||||
|
SET completed_dungeon_parts = ?, completed_raid_phases = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
clampInteger(save.completedDungeonParts, 0, 0, 3),
|
||||||
|
clampInteger(save.completedRaidPhases, 0, 0, 3),
|
||||||
|
accountId,
|
||||||
|
)
|
||||||
|
|
||||||
|
const replaceSlot = database.prepare(`
|
||||||
|
INSERT INTO character_ability_slots (character_id, slot_number, spell_id)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`)
|
||||||
|
const insertTalent = database.prepare(`
|
||||||
|
INSERT INTO character_talents (character_id, talent_id, rank)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`)
|
||||||
|
const insertInventory = database.prepare(`
|
||||||
|
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`)
|
||||||
|
|
||||||
|
for (const classId of classIds) {
|
||||||
|
const local = save.characters[classId]
|
||||||
|
if (!local || typeof local !== 'object') continue
|
||||||
|
|
||||||
|
const characterId = charactersByClass.get(classId).id
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE characters
|
||||||
|
SET name = ?, level = ?, experience = ?, talent_points = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
characterName,
|
||||||
|
clampInteger(local.level, 1, 1, maxLevel),
|
||||||
|
clampInteger(local.experience, 0, 0, maxExperience),
|
||||||
|
clampInteger(local.talentPoints, 1, 0, maxTalentPoints),
|
||||||
|
characterId,
|
||||||
|
)
|
||||||
|
|
||||||
|
const rawSlots = Array.isArray(local.abilitySlots)
|
||||||
|
? local.abilitySlots.slice(0, 6)
|
||||||
|
: []
|
||||||
|
while (rawSlots.length < 6) rawSlots.push(null)
|
||||||
|
const validSpellIds = spellIdsByClass.get(classId) ?? new Set()
|
||||||
|
const seenSpellIds = new Set()
|
||||||
|
const normalizedSlots = rawSlots.map((value) => {
|
||||||
|
if (value === null) return null
|
||||||
|
const spellId = Number(value)
|
||||||
|
if (
|
||||||
|
!Number.isInteger(spellId)
|
||||||
|
|| !validSpellIds.has(spellId)
|
||||||
|
|| seenSpellIds.has(spellId)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
seenSpellIds.add(spellId)
|
||||||
|
return spellId
|
||||||
|
})
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM character_ability_slots
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).run(characterId)
|
||||||
|
normalizedSlots.forEach((spellId, index) => {
|
||||||
|
replaceSlot.run(characterId, index + 1, spellId)
|
||||||
|
})
|
||||||
|
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM character_talents
|
||||||
|
WHERE character_id = ?
|
||||||
|
AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
|
||||||
|
`).run(characterId, classId)
|
||||||
|
const localTalentRanks = local.talentRanks && typeof local.talentRanks === 'object'
|
||||||
|
? local.talentRanks
|
||||||
|
: {}
|
||||||
|
for (const talent of talentRowsByClass.get(classId) ?? []) {
|
||||||
|
const rank = clampInteger(localTalentRanks[String(talent.id)], 0, 0, talent.maxRank)
|
||||||
|
if (rank > 0) {
|
||||||
|
insertTalent.run(characterId, talent.id, rank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM character_inventory
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).run(characterId)
|
||||||
|
const inventoryByItemId = new Map()
|
||||||
|
const equippedSlots = new Set()
|
||||||
|
for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
|
||||||
|
const itemId = Number(item?.id)
|
||||||
|
const slot = itemSlots.get(itemId)
|
||||||
|
const quantity = clampInteger(item?.quantity, 0, 0, 9999)
|
||||||
|
if (!slot || quantity <= 0) continue
|
||||||
|
const current = inventoryByItemId.get(itemId) ?? { quantity: 0, equipped: false }
|
||||||
|
current.quantity = Math.min(9999, current.quantity + quantity)
|
||||||
|
if (
|
||||||
|
Boolean(item?.equipped)
|
||||||
|
&& slot !== 'component'
|
||||||
|
&& !equippedSlots.has(slot)
|
||||||
|
) {
|
||||||
|
current.equipped = true
|
||||||
|
equippedSlots.add(slot)
|
||||||
|
}
|
||||||
|
inventoryByItemId.set(itemId, current)
|
||||||
|
}
|
||||||
|
for (const [itemId, itemState] of inventoryByItemId) {
|
||||||
|
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let syncedClassId = clampInteger(
|
||||||
|
save.activeClassId,
|
||||||
|
existingCharacters[0]?.classId ?? 1,
|
||||||
|
classIds[0] ?? 1,
|
||||||
|
classIds[classIds.length - 1] ?? 1,
|
||||||
|
)
|
||||||
|
if (!charactersByClass.has(syncedClassId)) {
|
||||||
|
syncedClassId = existingCharacters[0]?.classId ?? 1
|
||||||
|
}
|
||||||
|
const syncedCharacterId = charactersByClass.get(syncedClassId)?.id ?? activeCharacterId
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE sessions
|
||||||
|
SET active_character_id = ?
|
||||||
|
WHERE account_id = ?
|
||||||
|
`).run(syncedCharacterId, accountId)
|
||||||
|
|
||||||
|
database.exec('COMMIT')
|
||||||
|
return {
|
||||||
|
profile: getProfile(database, syncedCharacterId, accountId),
|
||||||
|
save: buildSyncSave(database, accountId, syncedCharacterId),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
database.exec('ROLLBACK')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function itemById(database, itemId) {
|
function itemById(database, itemId) {
|
||||||
return database.prepare(`
|
return database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -937,11 +1297,57 @@ function formatLootRoll(database, context, record, dropChance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function componentDropQuantity(droppedItemLevel) {
|
function coinDropQuantity() {
|
||||||
const tier = Math.max(0, Math.floor((droppedItemLevel - 5) / 5))
|
const roll = Math.random()
|
||||||
const secondChance = Math.min(0.85, 0.35 + tier * 0.12)
|
if (roll < 0.15) return 3
|
||||||
const thirdChance = Math.min(0.6, 0.1 + tier * 0.1)
|
if (roll < 0.5) return 2
|
||||||
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0)
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function roguelikeCoinItemLevel(stage) {
|
||||||
|
return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
function awardRoguelikeCoin(database, characterId, sourceEncounterId, stage) {
|
||||||
|
if (!sourceEncounterId || !stage) return null
|
||||||
|
const coin = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
items.id,
|
||||||
|
items.slug,
|
||||||
|
items.name,
|
||||||
|
items.slot,
|
||||||
|
items.rarity,
|
||||||
|
items.item_level AS itemLevel,
|
||||||
|
items.healing_power AS healingPower,
|
||||||
|
items.max_resource_bonus AS maxResourceBonus,
|
||||||
|
items.glyph,
|
||||||
|
items.description
|
||||||
|
FROM encounter_loot
|
||||||
|
JOIN items ON items.id = encounter_loot.item_id
|
||||||
|
WHERE encounter_loot.encounter_id = ?
|
||||||
|
AND items.item_level = ?
|
||||||
|
ORDER BY encounter_loot.difficulty_id
|
||||||
|
LIMIT 1
|
||||||
|
`).get(sourceEncounterId, roguelikeCoinItemLevel(stage))
|
||||||
|
if (!coin) return null
|
||||||
|
const quantity = coinDropQuantity()
|
||||||
|
const previousQuantity = database.prepare(`
|
||||||
|
SELECT quantity
|
||||||
|
FROM character_inventory
|
||||||
|
WHERE character_id = ? AND item_id = ?
|
||||||
|
`).get(characterId, coin.id)?.quantity ?? 0
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||||
|
VALUES (?, ?, ?, 0)
|
||||||
|
ON CONFLICT(character_id, item_id)
|
||||||
|
DO UPDATE SET quantity = quantity + ?
|
||||||
|
`).run(characterId, coin.id, quantity, quantity)
|
||||||
|
return {
|
||||||
|
...coin,
|
||||||
|
quantity,
|
||||||
|
duplicate: previousQuantity > 0,
|
||||||
|
quantityAfter: previousQuantity + quantity,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rollWeightedLootEntry(entries) {
|
function rollWeightedLootEntry(entries) {
|
||||||
@@ -1044,13 +1450,11 @@ function rollEncounterLoot(database, characterId, encounterId, difficultyId, run
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedQuantities = new Map()
|
const selectedQuantities = new Map()
|
||||||
const lootChanceSlots = context.contentType === 'raid' ? 8 : 5
|
if (Math.random() < dropChance) {
|
||||||
for (let index = 0; index < lootChanceSlots; index += 1) {
|
|
||||||
if (Math.random() >= dropChance) continue
|
|
||||||
const selected = rollWeightedLootEntry(entries)
|
const selected = rollWeightedLootEntry(entries)
|
||||||
selectedQuantities.set(
|
selectedQuantities.set(
|
||||||
selected.id,
|
selected.id,
|
||||||
(selectedQuantities.get(selected.id) ?? 0) + componentDropQuantity(context.droppedItemLevel),
|
coinDropQuantity(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1281,11 +1685,24 @@ function craftItem(database, characterId, recipeId) {
|
|||||||
crafting_recipes.item_id AS itemId,
|
crafting_recipes.item_id AS itemId,
|
||||||
crafting_recipes.difficulty_id AS difficultyId,
|
crafting_recipes.difficulty_id AS difficultyId,
|
||||||
crafting_recipes.source_dungeon_id AS sourceDungeonId,
|
crafting_recipes.source_dungeon_id AS sourceDungeonId,
|
||||||
crafting_recipes.source_encounter_id AS sourceEncounterId
|
crafting_recipes.source_encounter_id AS sourceEncounterId,
|
||||||
|
items.slot,
|
||||||
|
items.item_level AS itemLevel
|
||||||
FROM crafting_recipes
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
WHERE crafting_recipes.id = ?
|
WHERE crafting_recipes.id = ?
|
||||||
`).get(recipeId)
|
`).get(recipeId)
|
||||||
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
||||||
|
const lowerTierRecipe = database.prepare(`
|
||||||
|
SELECT crafting_recipes.id
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
|
WHERE crafting_recipes.source_encounter_id = ?
|
||||||
|
AND items.slot = ?
|
||||||
|
AND items.item_level < ?
|
||||||
|
LIMIT 1
|
||||||
|
`).get(recipe.sourceEncounterId, recipe.slot, recipe.itemLevel)
|
||||||
|
if (lowerTierRecipe) throw new Error('Upgrade the previous item tier instead.')
|
||||||
|
|
||||||
const components = database.prepare(`
|
const components = database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1334,6 +1751,104 @@ function craftItem(database, characterId, recipeId) {
|
|||||||
return getProfile(database, characterId)
|
return getProfile(database, characterId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upgradeItem(database, characterId, itemId) {
|
||||||
|
const item = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
items.id,
|
||||||
|
items.name,
|
||||||
|
items.slot,
|
||||||
|
items.item_level AS itemLevel,
|
||||||
|
character_inventory.quantity,
|
||||||
|
character_inventory.equipped
|
||||||
|
FROM character_inventory
|
||||||
|
JOIN items ON items.id = character_inventory.item_id
|
||||||
|
WHERE character_inventory.character_id = ?
|
||||||
|
AND items.id = ?
|
||||||
|
`).get(characterId, itemId)
|
||||||
|
if (!item) throw new Error('That item is not in the character inventory.')
|
||||||
|
if (item.slot === componentSlot) throw new Error('Components cannot be upgraded.')
|
||||||
|
|
||||||
|
const currentRecipe = database.prepare(`
|
||||||
|
SELECT source_encounter_id AS sourceEncounterId
|
||||||
|
FROM crafting_recipes
|
||||||
|
WHERE item_id = ?
|
||||||
|
`).get(itemId)
|
||||||
|
if (!currentRecipe) throw new Error('No upgrade is available for this item.')
|
||||||
|
|
||||||
|
const targetRecipe = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
crafting_recipes.id,
|
||||||
|
crafting_recipes.item_id AS itemId
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
|
WHERE crafting_recipes.source_encounter_id = ?
|
||||||
|
AND items.slot = ?
|
||||||
|
AND items.item_level > ?
|
||||||
|
ORDER BY items.item_level
|
||||||
|
LIMIT 1
|
||||||
|
`).get(currentRecipe.sourceEncounterId, item.slot, item.itemLevel)
|
||||||
|
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
|
||||||
|
|
||||||
|
const components = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
crafting_recipe_components.item_id AS itemId,
|
||||||
|
crafting_recipe_components.quantity,
|
||||||
|
COALESCE(character_inventory.quantity, 0) AS owned
|
||||||
|
FROM crafting_recipe_components
|
||||||
|
LEFT JOIN character_inventory
|
||||||
|
ON character_inventory.item_id = crafting_recipe_components.item_id
|
||||||
|
AND character_inventory.character_id = ?
|
||||||
|
WHERE crafting_recipe_components.recipe_id = ?
|
||||||
|
`).all(characterId, targetRecipe.id)
|
||||||
|
const missing = components.find((component) => component.owned < component.quantity)
|
||||||
|
if (missing) {
|
||||||
|
const componentItem = itemById(database, missing.itemId)
|
||||||
|
throw new Error(`Need ${missing.quantity} ${componentItem?.name ?? 'component'} to upgrade this item.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
database.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
for (const component of components) {
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE character_inventory
|
||||||
|
SET quantity = quantity - ?
|
||||||
|
WHERE character_id = ? AND item_id = ?
|
||||||
|
`).run(component.quantity, characterId, component.itemId)
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE character_inventory
|
||||||
|
SET quantity = quantity - 1,
|
||||||
|
equipped = 0
|
||||||
|
WHERE character_id = ? AND item_id = ?
|
||||||
|
`).run(characterId, itemId)
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM character_inventory
|
||||||
|
WHERE character_id = ? AND quantity <= 0
|
||||||
|
`).run(characterId)
|
||||||
|
if (item.equipped) {
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE character_inventory
|
||||||
|
SET equipped = 0
|
||||||
|
WHERE character_id = ?
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot = ?)
|
||||||
|
`).run(characterId, item.slot)
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||||
|
VALUES (?, ?, 1, ?)
|
||||||
|
ON CONFLICT(character_id, item_id)
|
||||||
|
DO UPDATE SET quantity = quantity + 1,
|
||||||
|
equipped = CASE WHEN excluded.equipped = 1 THEN 1 ELSE equipped END
|
||||||
|
`).run(characterId, targetRecipe.itemId, item.equipped ? 1 : 0)
|
||||||
|
database.exec('COMMIT')
|
||||||
|
} catch (error) {
|
||||||
|
database.exec('ROLLBACK')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return getProfile(database, characterId)
|
||||||
|
}
|
||||||
|
|
||||||
function allocateTalent(database, characterId, talentId) {
|
function allocateTalent(database, characterId, talentId) {
|
||||||
const character = database.prepare(`
|
const character = database.prepare(`
|
||||||
SELECT class_id AS classId, talent_points AS talentPoints
|
SELECT class_id AS classId, talent_points AS talentPoints
|
||||||
@@ -1622,7 +2137,7 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
|||||||
ON CONFLICT(character_id, item_id)
|
ON CONFLICT(character_id, item_id)
|
||||||
DO UPDATE SET quantity = quantity + 1
|
DO UPDATE SET quantity = quantity + 1
|
||||||
`).run(characterId, bonusItem.id)
|
`).run(characterId, bonusItem.id)
|
||||||
bonusItem = { ...bonusItem, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
|
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1777,6 +2292,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
SET experience = ?, level = ?, talent_points = ?
|
SET experience = ?, level = ?, talent_points = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(newExperience, newLevel, newTalentPoints, characterId)
|
`).run(newExperience, newLevel, newTalentPoints, characterId)
|
||||||
|
const bonusItem = awardRoguelikeCoin(
|
||||||
|
database,
|
||||||
|
characterId,
|
||||||
|
Number(runMetrics?.lootSourceEncounterId),
|
||||||
|
Number(runMetrics?.roguelikeStage),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dungeonName: `${dungeon.name} Roguelike`,
|
dungeonName: `${dungeon.name} Roguelike`,
|
||||||
@@ -1791,7 +2312,7 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
durationSeconds,
|
durationSeconds,
|
||||||
averageItemLevel,
|
averageItemLevel,
|
||||||
unlockedAbilities,
|
unlockedAbilities,
|
||||||
bonusItem: null,
|
bonusItem,
|
||||||
profile: getProfile(database, characterId, accountId),
|
profile: getProfile(database, characterId, accountId),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1880,12 +2401,124 @@ export function gameApiPlugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAuthApiRoute(database, request, response) {
|
||||||
|
if (request.url === '/api/auth/register' && request.method === 'POST') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
const result = registerAccount(database, request, payload)
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
201,
|
||||||
|
{ account: result.account, profile: result.profile, token: result.token },
|
||||||
|
{ 'Set-Cookie': sessionCookie(result.token, request) },
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/auth/login' && request.method === 'POST') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
const result = loginAccount(database, request, payload)
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
{ account: result.account, profile: result.profile, token: result.token },
|
||||||
|
{ 'Set-Cookie': sessionCookie(result.token, request) },
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/auth/session' && request.method === 'GET') {
|
||||||
|
const session = currentSession(database, request)
|
||||||
|
if (!session) {
|
||||||
|
sendJson(response, 200, { account: null, profile: null })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sendJson(response, 200, {
|
||||||
|
account: { id: session.accountId, username: session.username },
|
||||||
|
profile: getProfile(database, session.characterId, session.accountId),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/auth/logout' && request.method === 'POST') {
|
||||||
|
const token = requestSessionToken(request)
|
||||||
|
if (token) {
|
||||||
|
database.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash(token))
|
||||||
|
}
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
{ ok: true },
|
||||||
|
{ 'Set-Cookie': sessionCookie('', request, 0) },
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAuthApiRequest(request, response, next = null) {
|
||||||
|
if (!request.url?.startsWith('/api/auth/')) {
|
||||||
|
if (next) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJson(response, 404, { error: 'API route not found.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
sendCorsPreflight(request, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCorsHeaders(response, request)
|
||||||
|
|
||||||
|
if (!existsSync(databasePath)) {
|
||||||
|
sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = new DatabaseSync(databasePath)
|
||||||
|
database.exec('PRAGMA foreign_keys = ON')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ip = requestIp(request)
|
||||||
|
consumeRateLimit(`auth:${ip}`, 120, 60 * 1000)
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
||||||
|
`).run()
|
||||||
|
if (!(await handleAuthApiRoute(database, request, response))) {
|
||||||
|
sendJson(response, 404, { error: 'API route not found.' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const status = Number(error?.status) || 400
|
||||||
|
const headers = error?.retryAfter
|
||||||
|
? { 'Retry-After': String(error.retryAfter) }
|
||||||
|
: {}
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
status,
|
||||||
|
{ error: error instanceof Error ? error.message : 'Unable to process request.' },
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
database.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleApiRequest(request, response, next) {
|
export async function handleApiRequest(request, response, next) {
|
||||||
if (!request.url?.startsWith('/api/')) {
|
if (!request.url?.startsWith('/api/')) {
|
||||||
next()
|
next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
sendCorsPreflight(request, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCorsHeaders(response, request)
|
||||||
|
|
||||||
if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') {
|
if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') {
|
||||||
sendBossImage(request, response)
|
sendBossImage(request, response)
|
||||||
return
|
return
|
||||||
@@ -1911,59 +2544,23 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
||||||
`).run()
|
`).run()
|
||||||
|
|
||||||
if (request.url === '/api/auth/register' && request.method === 'POST') {
|
if (await handleAuthApiRoute(database, request, response)) {
|
||||||
const payload = await readJson(request)
|
|
||||||
const result = registerAccount(database, request, payload)
|
|
||||||
sendJson(
|
|
||||||
response,
|
|
||||||
201,
|
|
||||||
{ account: result.account, profile: result.profile },
|
|
||||||
{ 'Set-Cookie': sessionCookie(result.token, request) },
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.url === '/api/auth/login' && request.method === 'POST') {
|
|
||||||
const payload = await readJson(request)
|
|
||||||
const result = loginAccount(database, request, payload)
|
|
||||||
sendJson(
|
|
||||||
response,
|
|
||||||
200,
|
|
||||||
{ account: result.account, profile: result.profile },
|
|
||||||
{ 'Set-Cookie': sessionCookie(result.token, request) },
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.url === '/api/auth/session' && request.method === 'GET') {
|
|
||||||
const session = currentSession(database, request)
|
|
||||||
if (!session) {
|
|
||||||
sendJson(response, 200, { account: null, profile: null })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendJson(response, 200, {
|
|
||||||
account: { id: session.accountId, username: session.username },
|
|
||||||
profile: getProfile(database, session.characterId, session.accountId),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.url === '/api/auth/logout' && request.method === 'POST') {
|
|
||||||
const token = parseCookies(request)[sessionCookieName]
|
|
||||||
if (token) {
|
|
||||||
database.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash(token))
|
|
||||||
}
|
|
||||||
sendJson(
|
|
||||||
response,
|
|
||||||
200,
|
|
||||||
{ ok: true },
|
|
||||||
{ 'Set-Cookie': sessionCookie('', request, 0) },
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = requireSession(database, request)
|
const session = requireSession(database, request)
|
||||||
|
|
||||||
|
if (request.url === '/api/profile/sync-save' && request.method === 'GET') {
|
||||||
|
sendJson(response, 200, buildSyncSave(database, session.accountId, session.characterId))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/profile/sync-save' && request.method === 'PUT') {
|
||||||
|
const payload = await readJson(request, 512 * 1024)
|
||||||
|
sendJson(response, 200, importSyncSave(database, session.accountId, session.characterId, payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (request.url === '/api/profile' && request.method === 'GET') {
|
if (request.url === '/api/profile' && request.method === 'GET') {
|
||||||
sendJson(response, 200, getProfile(database, session.characterId, session.accountId))
|
sendJson(response, 200, getProfile(database, session.characterId, session.accountId))
|
||||||
return
|
return
|
||||||
@@ -2059,6 +2656,16 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
|
||||||
|
if (itemUpgrade && request.method === 'POST') {
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
|
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
|
||||||
if (encounterLootRoll && request.method === 'POST') {
|
if (encounterLootRoll && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
|
|||||||
+2934
-38
File diff suppressed because it is too large
Load Diff
+264
-90
@@ -19,7 +19,12 @@ import {
|
|||||||
type AuthSession,
|
type AuthSession,
|
||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
} from './profile'
|
} from './profile'
|
||||||
import { getGameMode, type GameMode } from './gameRepository'
|
import {
|
||||||
|
getCloudSyncStatus,
|
||||||
|
getGameMode,
|
||||||
|
syncCloudSave,
|
||||||
|
type GameMode,
|
||||||
|
} from './gameRepository'
|
||||||
import { focusFirstControl } from './input.tsx'
|
import { focusFirstControl } from './input.tsx'
|
||||||
|
|
||||||
type Screen =
|
type Screen =
|
||||||
@@ -40,8 +45,8 @@ const MENU_ITEMS: Array<{
|
|||||||
glyph: string
|
glyph: string
|
||||||
description: string
|
description: string
|
||||||
}> = [
|
}> = [
|
||||||
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a five-player party through dangerous encounters.' },
|
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a six-player party through dangerous encounters.' },
|
||||||
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide a ten-player party through three-phase challenges.' },
|
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' },
|
||||||
{ screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' },
|
{ screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' },
|
||||||
{ screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' },
|
{ screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' },
|
||||||
{ screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' },
|
{ screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' },
|
||||||
@@ -49,6 +54,7 @@ const MENU_ITEMS: Array<{
|
|||||||
]
|
]
|
||||||
|
|
||||||
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
||||||
|
const SHOW_LEADERBOARDS = false
|
||||||
|
|
||||||
function activityInitials(name: string) {
|
function activityInitials(name: string) {
|
||||||
return name
|
return name
|
||||||
@@ -88,6 +94,8 @@ function App() {
|
|||||||
const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence')
|
const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence')
|
||||||
const [showLeaderboard, setShowLeaderboard] = useState(false)
|
const [showLeaderboard, setShowLeaderboard] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [syncingCloud, setSyncingCloud] = useState(false)
|
||||||
|
const [syncMessage, setSyncMessage] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAuthSession()
|
loadAuthSession()
|
||||||
@@ -105,6 +113,17 @@ function App() {
|
|||||||
.finally(() => setAuthChecked(true))
|
.finally(() => setAuthChecked(true))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleModeChange = (event: Event) => {
|
||||||
|
const nextMode = (event as CustomEvent<GameMode>).detail
|
||||||
|
setGameMode(nextMode)
|
||||||
|
}
|
||||||
|
window.addEventListener('chronicle:mode-changed', handleModeChange as EventListener)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('chronicle:mode-changed', handleModeChange as EventListener)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (screen === 'combat') return
|
if (screen === 'combat') return
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
@@ -112,6 +131,13 @@ function App() {
|
|||||||
})
|
})
|
||||||
}, [screen])
|
}, [screen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authChecked || !account || !profile || screen === 'combat') return
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
focusFirstControl()
|
||||||
|
})
|
||||||
|
}, [account, authChecked, profile, screen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
|
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
|
||||||
}, [selectedDifficultyId])
|
}, [selectedDifficultyId])
|
||||||
@@ -129,6 +155,9 @@ function App() {
|
|||||||
setScreen('menu')
|
setScreen('menu')
|
||||||
setError('')
|
setError('')
|
||||||
setServerMessage('')
|
setServerMessage('')
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
focusFirstControl()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
@@ -138,11 +167,27 @@ function App() {
|
|||||||
setProfile(null)
|
setProfile(null)
|
||||||
setGameMode(getGameMode())
|
setGameMode(getGameMode())
|
||||||
setScreen('menu')
|
setScreen('menu')
|
||||||
|
setSyncMessage('')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
|
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncSaveNow() {
|
||||||
|
setSyncingCloud(true)
|
||||||
|
setSyncMessage('')
|
||||||
|
try {
|
||||||
|
const updated = await syncCloudSave()
|
||||||
|
setProfile(updated)
|
||||||
|
setGameMode(getGameMode())
|
||||||
|
setSyncMessage('Cloud save updated.')
|
||||||
|
} catch (reason) {
|
||||||
|
setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.')
|
||||||
|
} finally {
|
||||||
|
setSyncingCloud(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell">
|
<main className="game-shell">
|
||||||
@@ -237,10 +282,45 @@ function App() {
|
|||||||
?? dungeonOptions[0]!
|
?? dungeonOptions[0]!
|
||||||
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
||||||
?? raidOptions[0]
|
?? raidOptions[0]
|
||||||
const activity = screen === 'raids' && raid ? raid : dungeon
|
|
||||||
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
||||||
|
const startPveRoguelike = () => {
|
||||||
|
const baseDungeon = dungeonOptions[0]
|
||||||
|
const baseRaid = raidOptions[0]
|
||||||
|
if (roguelikeKind === 'raid') {
|
||||||
|
setCombatContentId(-2)
|
||||||
|
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
|
||||||
|
} else {
|
||||||
|
setCombatContentId(-1)
|
||||||
|
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
|
||||||
|
}
|
||||||
|
setSelectedPart(1)
|
||||||
|
setScreen('combat')
|
||||||
|
}
|
||||||
|
const tierOptions = activityOptions
|
||||||
|
.flatMap((option) => option.difficulties)
|
||||||
|
.filter((difficulty, index, all) => (
|
||||||
|
all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index
|
||||||
|
))
|
||||||
|
.sort((a, b) => a.droppedItemLevel - b.droppedItemLevel)
|
||||||
|
const savedDifficulty = profile.dungeons
|
||||||
|
.flatMap((option) => option.difficulties)
|
||||||
|
.find((candidate) => candidate.id === selectedDifficultyId)
|
||||||
|
const selectedTier = tierOptions.find((candidate) => (
|
||||||
|
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
|
||||||
|
&& profile.character.level >= candidate.unlockLevel
|
||||||
|
))
|
||||||
|
?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel)
|
||||||
|
?? tierOptions[0]
|
||||||
|
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
|
||||||
|
const tierActivityOptions = activityOptions.filter((option) =>
|
||||||
|
option.difficulties.some((difficulty) => difficulty.droppedItemLevel === selectedTierItemLevel),
|
||||||
|
)
|
||||||
|
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
|
||||||
|
const activity = tierActivityOptions.find((candidate) => candidate.id === selectedActivityId)
|
||||||
|
?? tierActivityOptions[0]
|
||||||
|
?? (screen === 'raids' && raid ? raid : dungeon)
|
||||||
const selectedDifficulty = activity.difficulties.find(
|
const selectedDifficulty = activity.difficulties.find(
|
||||||
(candidate) => candidate.id === selectedDifficultyId,
|
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
|
||||||
) ?? activity.difficulties[0]
|
) ?? activity.difficulties[0]
|
||||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||||
const completedSections = activity.contentType === 'raid'
|
const completedSections = activity.contentType === 'raid'
|
||||||
@@ -252,7 +332,8 @@ function App() {
|
|||||||
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
|
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
|
||||||
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
|
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
|
||||||
]
|
]
|
||||||
const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5
|
const cloudSync = getCloudSyncStatus()
|
||||||
|
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
||||||
const lootPreviewEncounters = [...activity.encounters]
|
const lootPreviewEncounters = [...activity.encounters]
|
||||||
.filter((encounter) => encounter.isBoss)
|
.filter((encounter) => encounter.isBoss)
|
||||||
.sort((a, b) => lootSort === 'boss'
|
.sort((a, b) => lootSort === 'boss'
|
||||||
@@ -260,7 +341,7 @@ function App() {
|
|||||||
: a.sequence - b.sequence)
|
: a.sequence - b.sequence)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="game-shell">
|
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''}`}>
|
||||||
<header className="topbar app-header">
|
<header className="topbar app-header">
|
||||||
<button
|
<button
|
||||||
className="brand-button"
|
className="brand-button"
|
||||||
@@ -285,6 +366,28 @@ function App() {
|
|||||||
{screen === 'menu' && (
|
{screen === 'menu' && (
|
||||||
<section className="menu-screen">
|
<section className="menu-screen">
|
||||||
<div className="main-menu-grid">
|
<div className="main-menu-grid">
|
||||||
|
{canShowCloudSync && (
|
||||||
|
<div className="menu-card cloud-sync-card">
|
||||||
|
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
|
||||||
|
<div>
|
||||||
|
<strong>Cloud Save</strong>
|
||||||
|
<small>
|
||||||
|
{cloudSync.dirty
|
||||||
|
? 'Local progress waiting. Upload when you want to refresh the server copy.'
|
||||||
|
: 'Server copy matches this device.'}
|
||||||
|
</small>
|
||||||
|
{syncMessage && <small className="cloud-sync-message">{syncMessage}</small>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="text-button"
|
||||||
|
disabled={syncingCloud || !cloudSync.dirty}
|
||||||
|
onClick={syncSaveNow}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{syncingCloud ? 'Syncing...' : cloudSync.dirty ? 'Sync Save To Server' : 'Already Synced'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{MENU_ITEMS.map((item) => (
|
{MENU_ITEMS.map((item) => (
|
||||||
<button
|
<button
|
||||||
className="menu-card"
|
className="menu-card"
|
||||||
@@ -335,6 +438,28 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
{roguelikeVariant === 'pve' && (
|
{roguelikeVariant === 'pve' && (
|
||||||
<>
|
<>
|
||||||
|
<div className="roguelike-option-panel">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Run Type</p>
|
||||||
|
<h2>PvE Roguelike</h2>
|
||||||
|
</div>
|
||||||
|
<div className="roguelike-timing-row">
|
||||||
|
<button
|
||||||
|
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''}`}
|
||||||
|
onClick={() => setRoguelikeKind('dungeon')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Dungeon
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''}`}
|
||||||
|
onClick={() => setRoguelikeKind('raid')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Raid
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="roguelike-option-panel">
|
<div className="roguelike-option-panel">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Upgrade Timing</p>
|
<p className="eyebrow">Upgrade Timing</p>
|
||||||
@@ -379,38 +504,22 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="roguelike-mode-grid">
|
<div className="menu-card pvp-queue-panel">
|
||||||
|
<span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'}</strong>
|
||||||
|
<small>
|
||||||
|
{roguelikeKind === 'raid'
|
||||||
|
? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
|
||||||
|
: 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="menu-card"
|
className="text-button"
|
||||||
onClick={() => {
|
onClick={startPveRoguelike}
|
||||||
const baseDungeon = dungeonOptions[0]
|
|
||||||
setRoguelikeKind('dungeon')
|
|
||||||
setCombatContentId(-1)
|
|
||||||
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
|
|
||||||
setSelectedPart(1)
|
|
||||||
setScreen('combat')
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>D</span>
|
Start Run
|
||||||
<strong>Dungeon Roguelike</strong>
|
|
||||||
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="menu-card"
|
|
||||||
onClick={() => {
|
|
||||||
const baseRaid = raidOptions[0]
|
|
||||||
setRoguelikeKind('raid')
|
|
||||||
setCombatContentId(-2)
|
|
||||||
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
|
|
||||||
setSelectedPart(1)
|
|
||||||
setScreen('combat')
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span>R</span>
|
|
||||||
<strong>Raid Roguelike</strong>
|
|
||||||
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -457,6 +566,7 @@ function App() {
|
|||||||
Start Match
|
Start Match
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{SHOW_LEADERBOARDS && (
|
||||||
<div className="leaderboard-section">
|
<div className="leaderboard-section">
|
||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -488,56 +598,135 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(screen === 'dungeons' || screen === 'raids') && (
|
{(screen === 'dungeons' || screen === 'raids') && (
|
||||||
<section className="content-screen">
|
<section className="content-screen dungeon-run-screen">
|
||||||
<ScreenHeading
|
<ScreenHeading
|
||||||
eyebrow="Adventure"
|
eyebrow="Adventure"
|
||||||
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
|
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
/>
|
/>
|
||||||
<article className="dungeon-card">
|
<div className="dungeon-run-board">
|
||||||
|
<div className="dungeon-run-main">
|
||||||
|
<article className="run-summary-card dungeon-focus-card">
|
||||||
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
|
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||||
{activityInitials(activity.name)}
|
{activityInitials(activity.name)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="run-summary-copy">
|
||||||
<p className="eyebrow">{activity.locationName}</p>
|
<p className="eyebrow">Selected Run</p>
|
||||||
<h2>{activity.name}</h2>
|
<h2>{activity.name}</h2>
|
||||||
<p>{activity.description}</p>
|
<p>{activity.description}</p>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span>Level {activity.recommendedLevel}</span>
|
<span>Level {activity.recommendedLevel}</span>
|
||||||
<span>{activity.partySize} Players</span>
|
<span>{activity.partySize} Players</span>
|
||||||
<span>{selectedDifficulty.name}</span>
|
<span>{selectedDifficulty.name}</span>
|
||||||
<span>Component Level {selectedDifficulty.droppedItemLevel}</span>
|
<span>iLvl {selectedDifficulty.droppedItemLevel}</span>
|
||||||
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
|
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activityOptions.length > 1 && (
|
</article>
|
||||||
<label className="activity-select">
|
|
||||||
<span>{activity.contentType === 'raid' ? 'Raid' : 'Dungeon'}</span>
|
<section className="run-setup-panel dungeon-choice-panel">
|
||||||
<select
|
<div className="run-setup-heading">
|
||||||
value={activity.id}
|
<div>
|
||||||
onChange={(event) => {
|
<p className="eyebrow">Pick Run</p>
|
||||||
const nextActivityId = Number(event.target.value)
|
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
|
||||||
const nextActivity = activityOptions.find((candidate) => candidate.id === nextActivityId)
|
</div>
|
||||||
if (screen === 'raids') setSelectedRaidId(nextActivityId)
|
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
|
||||||
else setSelectedDungeonId(nextActivityId)
|
</div>
|
||||||
if (nextActivity?.difficulties[0]) {
|
<div className="activity-card-grid dungeon-choice-grid">
|
||||||
setSelectedDifficultyId(nextActivity.difficulties[0].id)
|
{tierActivityOptions.map((candidate) => {
|
||||||
|
const difficulty = candidate.difficulties.find(
|
||||||
|
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
||||||
|
) ?? candidate.difficulties[0]
|
||||||
|
const locked = profile.character.level < difficulty.unlockLevel
|
||||||
|
const selected = candidate.id === activity.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||||
|
disabled={locked}
|
||||||
|
key={candidate.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||||
|
else setSelectedDungeonId(candidate.id)
|
||||||
|
setSelectedDifficultyId(difficulty.id)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||||
|
{activityInitials(candidate.name)}
|
||||||
|
</span>
|
||||||
|
<strong>{candidate.name}</strong>
|
||||||
|
<small>{candidate.locationName}</small>
|
||||||
|
<i>
|
||||||
|
Level {candidate.recommendedLevel} | {candidate.partySize} Players
|
||||||
|
</i>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="dungeon-setup-rail">
|
||||||
|
<section className="run-setup-panel tier-setup-panel">
|
||||||
|
<div className="run-setup-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Item Level</p>
|
||||||
|
<h2>Tier</h2>
|
||||||
|
</div>
|
||||||
|
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
|
||||||
|
</div>
|
||||||
|
<div className="tier-grid">
|
||||||
|
{tierOptions.map((difficulty) => {
|
||||||
|
const locked = profile.character.level < difficulty.unlockLevel
|
||||||
|
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||||
|
disabled={locked}
|
||||||
|
key={difficulty.id}
|
||||||
|
onClick={() => {
|
||||||
|
const nextActivity = activity.difficulties.some(
|
||||||
|
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||||
|
)
|
||||||
|
? activity
|
||||||
|
: activityOptions.find((option) =>
|
||||||
|
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
|
||||||
|
)
|
||||||
|
if (nextActivity) {
|
||||||
|
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
|
||||||
|
else setSelectedDungeonId(nextActivity.id)
|
||||||
|
const nextDifficulty = nextActivity.difficulties.find(
|
||||||
|
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||||
|
)
|
||||||
|
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{activityOptions.map((candidate) => (
|
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
||||||
<option key={candidate.id} value={candidate.id}>{candidate.name}</option>
|
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
|
||||||
))}
|
</button>
|
||||||
</select>
|
)
|
||||||
</label>
|
})}
|
||||||
)}
|
</div>
|
||||||
<div className="part-buttons">
|
</section>
|
||||||
|
|
||||||
|
<section className="run-setup-panel part-setup-panel">
|
||||||
|
<div className="run-setup-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Start</p>
|
||||||
|
<h2>{sectionName}</h2>
|
||||||
|
</div>
|
||||||
|
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : 'Choose a section to launch.'}</small>
|
||||||
|
</div>
|
||||||
|
<div className="part-picker">
|
||||||
{parts.map((p) => (
|
{parts.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.part}
|
key={p.part}
|
||||||
@@ -546,6 +735,7 @@ function App() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedPart(p.part)
|
setSelectedPart(p.part)
|
||||||
setCombatContentId(activity.id)
|
setCombatContentId(activity.id)
|
||||||
|
setSelectedDifficultyId(selectedDifficulty.id)
|
||||||
setScreen('combat')
|
setScreen('combat')
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -554,34 +744,9 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</section>
|
||||||
|
|
||||||
<div className="difficulty-section compact-difficulty-section">
|
<div className="difficulty-section compact-difficulty-section">
|
||||||
<div className="difficulty-select-row">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Challenge Tier</p>
|
|
||||||
<h2>Difficulty</h2>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>Select</span>
|
|
||||||
<select
|
|
||||||
value={selectedDifficulty.id}
|
|
||||||
onChange={(event) => setSelectedDifficultyId(Number(event.target.value))}
|
|
||||||
>
|
|
||||||
{activity.difficulties.map((difficulty, index) => (
|
|
||||||
<option
|
|
||||||
disabled={profile.character.level < difficulty.unlockLevel}
|
|
||||||
key={difficulty.id}
|
|
||||||
value={difficulty.id}
|
|
||||||
>
|
|
||||||
{index + 1}. {difficulty.name}
|
|
||||||
{profile.character.level < difficulty.unlockLevel
|
|
||||||
? ` - Level ${difficulty.unlockLevel}`
|
|
||||||
: ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
|
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{selectedDifficulty.name}</strong>
|
<strong>{selectedDifficulty.name}</strong>
|
||||||
@@ -591,10 +756,11 @@ function App() {
|
|||||||
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
|
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
|
||||||
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
|
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
|
||||||
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
|
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
|
||||||
<div><dt>Components</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
|
<div><dt>Loot</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="loot-preview-section">
|
<div className="loot-preview-section">
|
||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -621,7 +787,7 @@ function App() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p className="section-note">
|
<p className="section-note">
|
||||||
Bosses roll {lootChanceSlots} loot chances against the listed table, 1-3 materials each
|
Bosses drop 1-3 boss coins from one loot roll
|
||||||
{activity.completionItemLevel
|
{activity.completionItemLevel
|
||||||
? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
|
? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -641,7 +807,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<strong>{encounter.enemyName}</strong>
|
<strong>{encounter.enemyName}</strong>
|
||||||
<small>{loot.length > 0 ? `${lootChanceSlots} weighted chances, 1-3 each` : 'No component table'}</small>
|
<small>{loot.length > 0 ? '1 loot roll, 1-3 coins' : 'No coin table'}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="loot-items">
|
<div className="loot-items">
|
||||||
@@ -663,6 +829,7 @@ function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{SHOW_LEADERBOARDS && (
|
||||||
<div className="leaderboard-section">
|
<div className="leaderboard-section">
|
||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -682,6 +849,8 @@ function App() {
|
|||||||
<p className="section-note">
|
<p className="section-note">
|
||||||
{gameMode === 'offline'
|
{gameMode === 'offline'
|
||||||
? 'Offline runs are not submitted'
|
? 'Offline runs are not submitted'
|
||||||
|
: canShowCloudSync
|
||||||
|
? 'Manual save sync updates your cloud profile.'
|
||||||
: 'Lowest resource spent ranks first'}
|
: 'Lowest resource spent ranks first'}
|
||||||
</p>
|
</p>
|
||||||
<div className="leaderboard-tabs">
|
<div className="leaderboard-tabs">
|
||||||
@@ -730,6 +899,8 @@ function App() {
|
|||||||
<div className="leaderboard-empty">
|
<div className="leaderboard-empty">
|
||||||
{gameMode === 'offline'
|
{gameMode === 'offline'
|
||||||
? 'Connect with an online character to compete in rankings.'
|
? 'Connect with an online character to compete in rankings.'
|
||||||
|
: canShowCloudSync
|
||||||
|
? 'No leaderboard entries yet.'
|
||||||
: 'Complete this difficulty to claim the first ranking.'}
|
: 'Complete this difficulty to claim the first ranking.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -737,6 +908,9 @@ function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ function ItemsTab({ data, setData, setSaving, saving }: {
|
|||||||
</label>
|
</label>
|
||||||
<label>Rarity
|
<label>Rarity
|
||||||
<select value={form.rarity ?? item.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
|
<select value={form.rarity ?? item.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
|
||||||
{['common', 'uncommon', 'rare', 'epic'].map((r) => (
|
{['common', 'uncommon', 'rare', 'epic', 'legendary'].map((r) => (
|
||||||
<option key={r} value={r}>{r}</option>
|
<option key={r} value={r}>{r}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
|
|||||||
<h2>Play Offline</h2>
|
<h2>Play Offline</h2>
|
||||||
<p>
|
<p>
|
||||||
No account or connection required. Offline progress stays on
|
No account or connection required. Offline progress stays on
|
||||||
this device and is excluded from online leaderboards.
|
this device.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{offlineCharacterExists && (
|
{offlineCharacterExists && (
|
||||||
|
|||||||
+286
-144
@@ -10,6 +10,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
INITIAL_PARTY,
|
INITIAL_PARTY,
|
||||||
RAID_PARTY,
|
RAID_PARTY,
|
||||||
|
DEFAULT_GROUP_HEAL_TARGETS,
|
||||||
|
groupHealTargets,
|
||||||
|
partyDamageOutput,
|
||||||
|
tankPressureTargets,
|
||||||
type CombatLogEntry,
|
type CombatLogEntry,
|
||||||
type PartyMember,
|
type PartyMember,
|
||||||
type Spell,
|
type Spell,
|
||||||
@@ -73,6 +77,16 @@ type FloatingCombatText = {
|
|||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SinglePlayerCombatState = {
|
||||||
|
party: PartyMember[]
|
||||||
|
resource: number
|
||||||
|
enemyHealth: number
|
||||||
|
cooldowns: Record<string, number>
|
||||||
|
elapsedTicks: number
|
||||||
|
castsTowardFree: number
|
||||||
|
freeCastReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const ROGUELIKE_MECHANICS: RoguelikeMechanic[] = [
|
const ROGUELIKE_MECHANICS: RoguelikeMechanic[] = [
|
||||||
'party-pulse',
|
'party-pulse',
|
||||||
'searing-mark',
|
'searing-mark',
|
||||||
@@ -241,7 +255,7 @@ function makeRoguelikeSegment(
|
|||||||
encounter.maxHealth
|
encounter.maxHealth
|
||||||
+ encounter.damage * 18
|
+ encounter.damage * 18
|
||||||
+ encounter.tankDamage * 10
|
+ encounter.tankDamage * 10
|
||||||
+ encounter.partyDamage * 12
|
+ encounter.partyDamage * 18
|
||||||
)
|
)
|
||||||
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
|
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
|
||||||
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
||||||
@@ -331,7 +345,7 @@ export function CombatScreen({
|
|||||||
)
|
)
|
||||||
const maxResource = gameClass.maxResource + (isRoguelike ? 0 : profile.gearStats.maxResourceBonus)
|
const maxResource = gameClass.maxResource + (isRoguelike ? 0 : profile.gearStats.maxResourceBonus)
|
||||||
const partyTemplate = useMemo(
|
const partyTemplate = useMemo(
|
||||||
() => (dungeon.partySize === 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
() => (dungeon.partySize >= 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
||||||
...member,
|
...member,
|
||||||
name: member.id === 'mira' ? profile.character.name : member.name,
|
name: member.id === 'mira' ? profile.character.name : member.name,
|
||||||
})),
|
})),
|
||||||
@@ -340,16 +354,21 @@ export function CombatScreen({
|
|||||||
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
|
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
|
||||||
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
||||||
const initialEncounterIndex = (startPart - 1) * 3
|
const initialEncounterIndex = (startPart - 1) * 3
|
||||||
const [party, setParty] = useState<PartyMember[]>(partyTemplate)
|
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
|
||||||
|
party: partyTemplate,
|
||||||
|
resource: maxResource,
|
||||||
|
enemyHealth: encounters[initialEncounterIndex].maxHealth,
|
||||||
|
cooldowns: {},
|
||||||
|
elapsedTicks: 0,
|
||||||
|
castsTowardFree: 0,
|
||||||
|
freeCastReady: false,
|
||||||
|
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
|
||||||
|
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
|
||||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||||
const [resource, setResource] = useState(maxResource)
|
|
||||||
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
||||||
const [enemyHealth, setEnemyHealth] = useState(encounters[initialEncounterIndex].maxHealth)
|
|
||||||
const [cooldowns, setCooldowns] = useState<Record<string, number>>({})
|
|
||||||
const [elapsedTicks, setElapsedTicks] = useState(0)
|
|
||||||
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
|
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
|
||||||
const [paused, setPaused] = useState(false)
|
const [paused, setPaused] = useState(false)
|
||||||
const [targetGroup, setTargetGroup] = useState<0 | 1>(0)
|
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||||
const [log, setLog] = useState<CombatLogEntry[]>([
|
const [log, setLog] = useState<CombatLogEntry[]>([
|
||||||
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
|
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
|
||||||
])
|
])
|
||||||
@@ -360,8 +379,6 @@ export function CombatScreen({
|
|||||||
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
||||||
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
|
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
|
||||||
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
||||||
const [, setCastsTowardFree] = useState(0)
|
|
||||||
const [freeCastReady, setFreeCastReady] = useState(false)
|
|
||||||
const rewardClaimedRef = useRef(false)
|
const rewardClaimedRef = useRef(false)
|
||||||
const profileRefreshedRef = useRef(false)
|
const profileRefreshedRef = useRef(false)
|
||||||
const rolledEncounterIdsRef = useRef(new Set<number>())
|
const rolledEncounterIdsRef = useRef(new Set<number>())
|
||||||
@@ -371,8 +388,14 @@ export function CombatScreen({
|
|||||||
const partStartTimesRef = useRef<Record<number, number>>({})
|
const partStartTimesRef = useRef<Record<number, number>>({})
|
||||||
const nextLogId = useRef(2)
|
const nextLogId = useRef(2)
|
||||||
const nextFloatingTextId = useRef(1)
|
const nextFloatingTextId = useRef(1)
|
||||||
const partyRef = useRef(partyTemplate)
|
const combatRef = useRef(initialCombatState)
|
||||||
const enemyHealthRef = useRef(encounters[initialEncounterIndex].maxHealth)
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||||
|
const runCombatTickRef = useRef<() => void>(() => {})
|
||||||
|
const combatClockActiveRef = useRef(false)
|
||||||
|
const lastCombatTickAtRef = useRef(performance.now())
|
||||||
|
const statusRef = useRef(status)
|
||||||
|
const pausedRef = useRef(paused)
|
||||||
|
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
||||||
const encounter = encounters[encounterIndex]
|
const encounter = encounters[encounterIndex]
|
||||||
const currentPart = getCurrentPart(encounterIndex)
|
const currentPart = getCurrentPart(encounterIndex)
|
||||||
const firstEncounterIndex = (startPart - 1) * 3
|
const firstEncounterIndex = (startPart - 1) * 3
|
||||||
@@ -401,6 +424,9 @@ export function CombatScreen({
|
|||||||
enabled: dualScreenEnabled,
|
enabled: dualScreenEnabled,
|
||||||
} = useDualScreen()
|
} = useDualScreen()
|
||||||
|
|
||||||
|
statusRef.current = status
|
||||||
|
pausedRef.current = paused
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
runStartedAtRef.current = now
|
runStartedAtRef.current = now
|
||||||
@@ -414,6 +440,35 @@ export function CombatScreen({
|
|||||||
})
|
})
|
||||||
}, [paused])
|
}, [paused])
|
||||||
|
|
||||||
|
const setCombat = useCallback((
|
||||||
|
nextState: SinglePlayerCombatState | ((current: SinglePlayerCombatState) => SinglePlayerCombatState),
|
||||||
|
) => {
|
||||||
|
const next = typeof nextState === 'function'
|
||||||
|
? nextState(combatRef.current)
|
||||||
|
: nextState
|
||||||
|
combatRef.current = next
|
||||||
|
setSelectedId(selectedIdRef.current)
|
||||||
|
setCombatState(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const syncSelectedTargetDom = useCallback((id: string) => {
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-party-member-id]').forEach((button) => {
|
||||||
|
const selected = button.dataset.partyMemberId === id
|
||||||
|
button.classList.toggle('selected', selected)
|
||||||
|
button.setAttribute('aria-pressed', String(selected))
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSelectedTargetId = useCallback((id: string) => {
|
||||||
|
if (selectedIdRef.current === id) return
|
||||||
|
selectedIdRef.current = id
|
||||||
|
syncSelectedTargetDom(id)
|
||||||
|
}, [syncSelectedTargetDom])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncSelectedTargetDom(selectedIdRef.current)
|
||||||
|
}, [combatState, syncSelectedTargetDom])
|
||||||
|
|
||||||
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
||||||
const entry = { id: nextLogId.current++, text, tone }
|
const entry = { id: nextLogId.current++, text, tone }
|
||||||
setLog((current) => [entry, ...current].slice(0, 60))
|
setLog((current) => [entry, ...current].slice(0, 60))
|
||||||
@@ -461,17 +516,19 @@ export function CombatScreen({
|
|||||||
: []
|
: []
|
||||||
const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters
|
const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters
|
||||||
const freshParty = partyTemplate.map((member) => ({ ...member }))
|
const freshParty = partyTemplate.map((member) => ({ ...member }))
|
||||||
partyRef.current = freshParty
|
setCombat({
|
||||||
enemyHealthRef.current = nextEncounters[initialEncounterIndex].maxHealth
|
party: freshParty,
|
||||||
|
resource: maxResource,
|
||||||
|
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth,
|
||||||
|
cooldowns: {},
|
||||||
|
elapsedTicks: 0,
|
||||||
|
castsTowardFree: 0,
|
||||||
|
freeCastReady: false,
|
||||||
|
})
|
||||||
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
|
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
|
||||||
setRoguelikeStage(1)
|
setRoguelikeStage(1)
|
||||||
setParty(freshParty)
|
setSelectedTargetId(partyTemplate[0].id)
|
||||||
setSelectedId(partyTemplate[0].id)
|
|
||||||
setResource(maxResource)
|
|
||||||
setEncounterIndex(initialEncounterIndex)
|
setEncounterIndex(initialEncounterIndex)
|
||||||
setEnemyHealth(nextEncounters[initialEncounterIndex].maxHealth)
|
|
||||||
setCooldowns({})
|
|
||||||
setElapsedTicks(0)
|
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
setTargetGroup(0)
|
setTargetGroup(0)
|
||||||
@@ -482,8 +539,6 @@ export function CombatScreen({
|
|||||||
setFloatingTexts([])
|
setFloatingTexts([])
|
||||||
setRoguelikeUpgrades([])
|
setRoguelikeUpgrades([])
|
||||||
setUpgradeChoices([])
|
setUpgradeChoices([])
|
||||||
setCastsTowardFree(0)
|
|
||||||
setFreeCastReady(false)
|
|
||||||
rewardClaimedRef.current = false
|
rewardClaimedRef.current = false
|
||||||
profileRefreshedRef.current = false
|
profileRefreshedRef.current = false
|
||||||
rolledEncounterIdsRef.current = new Set()
|
rolledEncounterIdsRef.current = new Set()
|
||||||
@@ -492,36 +547,43 @@ export function CombatScreen({
|
|||||||
runStartedAtRef.current = Date.now()
|
runStartedAtRef.current = Date.now()
|
||||||
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
||||||
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
||||||
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, startPart, staticEncounters])
|
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
||||||
|
|
||||||
const castSpell = useCallback(
|
const castSpell = useCallback(
|
||||||
(spell: Spell) => {
|
(spell: Spell) => {
|
||||||
const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, freeCastReady)
|
const current = combatRef.current
|
||||||
if (status !== 'playing' || cooldowns[spell.id] > 0 || resource < effectiveCost) return
|
const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, current.freeCastReady)
|
||||||
const healer = partyRef.current.find((member) => member.id === 'mira')
|
if (status !== 'playing' || current.cooldowns[spell.id] > 0 || current.resource < effectiveCost) return
|
||||||
|
const healer = current.party.find((member) => member.id === 'mira')
|
||||||
if (!healer || healer.health <= 0) return
|
if (!healer || healer.health <= 0) return
|
||||||
const selected = partyRef.current.find((member) => member.id === selectedId)
|
const targetId = selectedIdRef.current
|
||||||
|
const selected = current.party.find((member) => member.id === targetId)
|
||||||
if (!selected || selected.health <= 0) return
|
if (!selected || selected.health <= 0) return
|
||||||
const extraTarget = (blockedIds: string[]) => partyRef.current
|
const extraTarget = (blockedIds: string[]) => current.party
|
||||||
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
||||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
||||||
const directTargets = new Set([selectedId])
|
const directTargets = new Set([targetId])
|
||||||
const hotTargets = new Set<string>()
|
const hotTargets = new Set<string>()
|
||||||
const shieldTargets = new Set<string>()
|
const shieldTargets = new Set<string>()
|
||||||
if (spell.kind === 'hot') hotTargets.add(selectedId)
|
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
|
||||||
if (spell.kind === 'shield') shieldTargets.add(selectedId)
|
const groupTargets = new Set(
|
||||||
|
spell.kind === 'group'
|
||||||
|
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
if (spell.kind === 'hot') hotTargets.add(targetId)
|
||||||
|
if (spell.kind === 'shield') shieldTargets.add(targetId)
|
||||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
|
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
|
||||||
const extra = extraTarget([selectedId])
|
const extra = extraTarget([targetId])
|
||||||
if (extra) directTargets.add(extra.id)
|
if (extra) directTargets.add(extra.id)
|
||||||
}
|
}
|
||||||
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) {
|
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) {
|
||||||
const extra = extraTarget([selectedId])
|
const extra = extraTarget([targetId])
|
||||||
if (extra) hotTargets.add(extra.id)
|
if (extra) hotTargets.add(extra.id)
|
||||||
}
|
}
|
||||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
|
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
|
||||||
hotTargets.add(selectedId)
|
hotTargets.add(targetId)
|
||||||
}
|
}
|
||||||
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
|
|
||||||
for (let index = 0; index < extraTargets; index += 1) {
|
for (let index = 0; index < extraTargets; index += 1) {
|
||||||
if (spell.kind === 'group') break
|
if (spell.kind === 'group') break
|
||||||
if (spell.kind === 'hot') {
|
if (spell.kind === 'hot') {
|
||||||
@@ -538,30 +600,10 @@ export function CombatScreen({
|
|||||||
if (extra) directTargets.add(extra.id)
|
if (extra) directTargets.add(extra.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
setResource((value) => value - effectiveCost)
|
const nextParty = current.party.map((member) => {
|
||||||
resourceSpentRef.current += effectiveCost
|
|
||||||
setCooldowns((current) => ({
|
|
||||||
...current,
|
|
||||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
|
|
||||||
}))
|
|
||||||
if (upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free') > 0) {
|
|
||||||
if (freeCastReady) {
|
|
||||||
setFreeCastReady(false)
|
|
||||||
setCastsTowardFree(0)
|
|
||||||
} else {
|
|
||||||
setCastsTowardFree((current) => {
|
|
||||||
const next = current + 1
|
|
||||||
if (next >= 5) {
|
|
||||||
setFreeCastReady(true)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const nextParty = partyRef.current.map((member) => {
|
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
if (spell.kind === 'group') {
|
if (spell.kind === 'group') {
|
||||||
|
if (!groupTargets.has(member.id)) return member
|
||||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
||||||
const nextHealth = healMember(member, power)
|
const nextHealth = healMember(member, power)
|
||||||
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
|
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
|
||||||
@@ -593,11 +635,35 @@ export function CombatScreen({
|
|||||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
partyRef.current = nextParty
|
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
|
||||||
setParty(nextParty)
|
const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady
|
||||||
|
? false
|
||||||
|
: current.freeCastReady
|
||||||
|
const nextCastsTowardFree = freeCastStacks > 0
|
||||||
|
? current.freeCastReady
|
||||||
|
? 0
|
||||||
|
: current.castsTowardFree + 1 >= 5
|
||||||
|
? 0
|
||||||
|
: current.castsTowardFree + 1
|
||||||
|
: current.castsTowardFree
|
||||||
|
const gainedFreeCast = freeCastStacks > 0
|
||||||
|
&& !current.freeCastReady
|
||||||
|
&& current.castsTowardFree + 1 >= 5
|
||||||
|
resourceSpentRef.current += effectiveCost
|
||||||
|
setCombat({
|
||||||
|
...current,
|
||||||
|
party: nextParty,
|
||||||
|
resource: current.resource - effectiveCost,
|
||||||
|
cooldowns: {
|
||||||
|
...current.cooldowns,
|
||||||
|
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
|
||||||
|
},
|
||||||
|
castsTowardFree: nextCastsTowardFree,
|
||||||
|
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||||
|
})
|
||||||
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
||||||
},
|
},
|
||||||
[activeSetEffects, addFloatingHeal, addLog, cooldowns, freeCastReady, resource, roguelikeUpgrades, selectedId, status],
|
[activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, status],
|
||||||
)
|
)
|
||||||
|
|
||||||
const finishRun = useCallback(
|
const finishRun = useCallback(
|
||||||
@@ -660,25 +726,25 @@ export function CombatScreen({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
||||||
const living = partyRef.current.filter((member) => member.health > 0)
|
const living = combatRef.current.party.filter((member) => member.health > 0)
|
||||||
if (living.length === 0) return
|
if (living.length === 0) return
|
||||||
const currentIndex = living.findIndex((member) => member.id === selectedId)
|
const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
|
||||||
const nextIndex = currentIndex < 0
|
const nextIndex = currentIndex < 0
|
||||||
? 0
|
? 0
|
||||||
: (currentIndex + direction + living.length) % living.length
|
: (currentIndex + direction + living.length) % living.length
|
||||||
setSelectedId(living[nextIndex].id)
|
setSelectedTargetId(living[nextIndex].id)
|
||||||
}, [selectedId])
|
}, [setSelectedTargetId])
|
||||||
|
|
||||||
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
||||||
const columns = dungeon.partySize === 10 ? 5 : 3
|
const columns = dungeon.partySize >= 10 ? 6 : 3
|
||||||
const currentIndex = partyRef.current.findIndex((member) => member.id === selectedId)
|
const currentIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
||||||
if (currentIndex < 0) {
|
if (currentIndex < 0) {
|
||||||
setSelectedId(partyRef.current[0].id)
|
setSelectedTargetId(combatRef.current.party[0].id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const currentRow = Math.floor(currentIndex / columns)
|
const currentRow = Math.floor(currentIndex / columns)
|
||||||
const currentColumn = currentIndex % columns
|
const currentColumn = currentIndex % columns
|
||||||
const candidates = partyRef.current
|
const candidates = combatRef.current.party
|
||||||
.map((member, index) => ({
|
.map((member, index) => ({
|
||||||
member,
|
member,
|
||||||
index,
|
index,
|
||||||
@@ -707,19 +773,20 @@ export function CombatScreen({
|
|||||||
: Math.abs(b.column - currentColumn)
|
: Math.abs(b.column - currentColumn)
|
||||||
return aPrimary - bPrimary || aSecondary - bSecondary
|
return aPrimary - bPrimary || aSecondary - bSecondary
|
||||||
})
|
})
|
||||||
if (candidates[0]) setSelectedId(candidates[0].member.id)
|
if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
|
||||||
}, [dungeon.partySize, selectedId])
|
}, [dungeon.partySize, setSelectedTargetId])
|
||||||
|
|
||||||
const selectDirectTarget = useCallback((slot: number) => {
|
const selectDirectTarget = useCallback((slot: number) => {
|
||||||
const index = slot + (dungeon.partySize === 10 ? targetGroup * 5 : 0)
|
const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0)
|
||||||
const member = partyRef.current[index]
|
const member = combatRef.current.party[index]
|
||||||
if (member) setSelectedId(member.id)
|
if (member) setSelectedTargetId(member.id)
|
||||||
}, [dungeon.partySize, targetGroup])
|
}, [dungeon.partySize, setSelectedTargetId, targetGroup])
|
||||||
|
|
||||||
const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
|
const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
|
||||||
if (!roguelikeMode) return
|
if (!roguelikeMode) return
|
||||||
|
const current = combatRef.current
|
||||||
const clearedBoss = encounters[encounterIndex]?.isBoss ?? false
|
const clearedBoss = encounters[encounterIndex]?.isBoss ?? false
|
||||||
const recoveredParty = partyRef.current.map((member) => ({
|
const recoveredParty = current.party.map((member) => ({
|
||||||
...member,
|
...member,
|
||||||
health: member.health <= 0
|
health: member.health <= 0
|
||||||
? 0
|
? 0
|
||||||
@@ -738,23 +805,24 @@ export function CombatScreen({
|
|||||||
? nextSegment[0]
|
? nextSegment[0]
|
||||||
: encounters[encounterIndex + 1]
|
: encounters[encounterIndex + 1]
|
||||||
if (!nextEncounter) return
|
if (!nextEncounter) return
|
||||||
partyRef.current = recoveredParty
|
|
||||||
enemyHealthRef.current = nextEncounter.maxHealth
|
|
||||||
setRoguelikeUpgrades((current) => [...current, upgrade])
|
setRoguelikeUpgrades((current) => [...current, upgrade])
|
||||||
if (clearedBoss) {
|
if (clearedBoss) {
|
||||||
setRoguelikeStage(nextStage)
|
setRoguelikeStage(nextStage)
|
||||||
setRoguelikeEncounters((current) => [...current, ...nextSegment])
|
setRoguelikeEncounters((current) => [...current, ...nextSegment])
|
||||||
}
|
}
|
||||||
setParty(recoveredParty)
|
|
||||||
setEncounterIndex((current) => current + 1)
|
setEncounterIndex((current) => current + 1)
|
||||||
setEnemyHealth(nextEncounter.maxHealth)
|
setCombat({
|
||||||
setElapsedTicks(0)
|
...current,
|
||||||
setCooldowns({})
|
party: recoveredParty,
|
||||||
setResource((value) => clamp(value + Math.round(maxResource * 0.25), 0, maxResource))
|
enemyHealth: nextEncounter.maxHealth,
|
||||||
|
elapsedTicks: 0,
|
||||||
|
cooldowns: {},
|
||||||
|
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||||
|
})
|
||||||
setUpgradeChoices([])
|
setUpgradeChoices([])
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||||
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage])
|
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||||
|
|
||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
||||||
@@ -771,12 +839,13 @@ export function CombatScreen({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'toggleTargetGroup') {
|
if (action === 'toggleTargetGroup') {
|
||||||
if (dungeon.partySize !== 10) return
|
if (dungeon.partySize <= 6) return
|
||||||
setTargetGroup((current) => {
|
setTargetGroup((current) => {
|
||||||
const next = current === 0 ? 1 : 0
|
const groupCount = Math.max(1, Math.ceil(combatRef.current.party.length / 6))
|
||||||
const selectedIndex = partyRef.current.findIndex((member) => member.id === selectedId)
|
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
||||||
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 5) + next * 5]
|
const selectedIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
||||||
if (nextMember) setSelectedId(nextMember.id)
|
const nextMember = combatRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
||||||
|
if (nextMember) setSelectedTargetId(nextMember.id)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -795,21 +864,18 @@ export function CombatScreen({
|
|||||||
if (spell) castSpell(spell)
|
if (spell) castSpell(spell)
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
const runCombatTick = useCallback(() => {
|
||||||
if (status !== 'playing' || paused) return
|
const current = combatRef.current
|
||||||
const timer = window.setInterval(() => {
|
const nextElapsedTicks = current.elapsedTicks + 1
|
||||||
setElapsedTicks((value) => value + 1)
|
const nextCooldowns = Object.fromEntries(
|
||||||
setResource((value) => clamp(value + 2.4, 0, maxResource))
|
Object.entries(current.cooldowns).map(([id, seconds]) => [
|
||||||
setCooldowns((current) =>
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(current).map(([id, seconds]) => [
|
|
||||||
id,
|
id,
|
||||||
Math.max(0, seconds - TICK_MS / 1000),
|
Math.max(0, seconds - TICK_MS / 1000),
|
||||||
]),
|
]),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
let nextResource = clamp(current.resource + 2.4, 0, maxResource)
|
||||||
|
|
||||||
const living = partyRef.current.filter((member) => member.health > 0)
|
const living = current.party.filter((member) => member.health > 0)
|
||||||
if (living.length === 0) {
|
if (living.length === 0) {
|
||||||
if (isRoguelike) finishRoguelikeRun(encounterIndex)
|
if (isRoguelike) finishRoguelikeRun(encounterIndex)
|
||||||
setStatus('lost')
|
setStatus('lost')
|
||||||
@@ -820,19 +886,19 @@ export function CombatScreen({
|
|||||||
const primaryTarget = living[Math.floor(Math.random() * living.length)]
|
const primaryTarget = living[Math.floor(Math.random() * living.length)]
|
||||||
const mechanics = (encounter as RoguelikeEncounter).roguelikeMechanics ?? []
|
const mechanics = (encounter as RoguelikeEncounter).roguelikeMechanics ?? []
|
||||||
const useDefaultBossMechanics = encounter.isBoss && mechanics.length === 0
|
const useDefaultBossMechanics = encounter.isBoss && mechanics.length === 0
|
||||||
const bossPulse = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 7 === 0
|
const bossPulse = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 7 === 0
|
||||||
&& (useDefaultBossMechanics || mechanics.includes('party-pulse'))
|
&& (useDefaultBossMechanics || mechanics.includes('party-pulse'))
|
||||||
const appliesDebuff = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 11 === 0
|
const appliesDebuff = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 11 === 0
|
||||||
&& (useDefaultBossMechanics || mechanics.includes('searing-mark'))
|
&& (useDefaultBossMechanics || mechanics.includes('searing-mark'))
|
||||||
const appliesMaxHealthCut = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 13 === 0
|
const appliesMaxHealthCut = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 13 === 0
|
||||||
&& mechanics.includes('max-health-cut')
|
&& mechanics.includes('max-health-cut')
|
||||||
const appliesHealingReduction = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0
|
const appliesHealingReduction = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 9 === 0
|
||||||
&& mechanics.includes('healing-reduction')
|
&& mechanics.includes('healing-reduction')
|
||||||
const tankBuster = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 8 === 0
|
const tankBuster = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 8 === 0
|
||||||
&& mechanics.includes('tank-buster')
|
&& mechanics.includes('tank-buster')
|
||||||
const resourceDrain = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 10 === 0
|
const resourceDrain = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 10 === 0
|
||||||
&& mechanics.includes('resource-drain')
|
&& mechanics.includes('resource-drain')
|
||||||
const appliesPoison = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0
|
const appliesPoison = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 12 === 0
|
||||||
&& mechanics.includes('ramping-poison')
|
&& mechanics.includes('ramping-poison')
|
||||||
if (bossPulse) addLog(`${encounter.enemyName} unleashes party-wide damage.`, 'danger')
|
if (bossPulse) addLog(`${encounter.enemyName} unleashes party-wide damage.`, 'danger')
|
||||||
if (appliesDebuff) addLog(`${primaryTarget.name} is afflicted by Searing Mark.`, 'danger')
|
if (appliesDebuff) addLog(`${primaryTarget.name} is afflicted by Searing Mark.`, 'danger')
|
||||||
@@ -841,16 +907,22 @@ export function CombatScreen({
|
|||||||
if (appliesHealingReduction) addLog(`${primaryTarget.name} receives reduced healing.`, 'danger')
|
if (appliesHealingReduction) addLog(`${primaryTarget.name} receives reduced healing.`, 'danger')
|
||||||
if (tankBuster) addLog(`${encounter.enemyName} crushes the tanks.`, 'danger')
|
if (tankBuster) addLog(`${encounter.enemyName} crushes the tanks.`, 'danger')
|
||||||
if (resourceDrain) {
|
if (resourceDrain) {
|
||||||
setResource((value) => clamp(value - 8, 0, maxResource))
|
nextResource = clamp(nextResource - 8, 0, maxResource)
|
||||||
addLog(`${encounter.enemyName} drains ${gameClass.resourceName}.`, 'danger')
|
addLog(`${encounter.enemyName} drains ${gameClass.resourceName}.`, 'danger')
|
||||||
}
|
}
|
||||||
|
|
||||||
const healerBeforeDamage = partyRef.current.find((member) => member.id === 'mira')
|
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
|
||||||
const nextParty = partyRef.current.map((member) => {
|
const tankPressure = tankPressureTargets(current.party)
|
||||||
|
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||||
|
const nextParty = current.party.map((member) => {
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
||||||
if (member.role === 'Tank') damage += encounter.tankDamage
|
if (tankPressureIds.has(member.id)) {
|
||||||
if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier)
|
damage += Math.round(encounter.tankDamage * tankPressure.multiplier)
|
||||||
|
}
|
||||||
|
if (tankBuster && tankPressureIds.has(member.id)) {
|
||||||
|
damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier)
|
||||||
|
}
|
||||||
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
|
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
|
||||||
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
|
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
|
||||||
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
||||||
@@ -885,8 +957,6 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
|
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
|
||||||
partyRef.current = nextParty
|
|
||||||
setParty(nextParty)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
healerBeforeDamage
|
healerBeforeDamage
|
||||||
@@ -898,16 +968,30 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nextParty.every((member) => member.health <= 0)) {
|
if (nextParty.every((member) => member.health <= 0)) {
|
||||||
|
setCombat({
|
||||||
|
...current,
|
||||||
|
party: nextParty,
|
||||||
|
resource: nextResource,
|
||||||
|
cooldowns: nextCooldowns,
|
||||||
|
elapsedTicks: nextElapsedTicks,
|
||||||
|
enemyHealth: current.enemyHealth,
|
||||||
|
})
|
||||||
if (isRoguelike) finishRoguelikeRun(encounterIndex)
|
if (isRoguelike) finishRoguelikeRun(encounterIndex)
|
||||||
setStatus('lost')
|
setStatus('lost')
|
||||||
addLog('The party has fallen.', 'danger')
|
addLog('The party has fallen.', 'danger')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextEnemyHealth = enemyHealthRef.current - encounter.partyDamage
|
const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage)
|
||||||
if (nextEnemyHealth > 0) {
|
if (nextEnemyHealth > 0) {
|
||||||
enemyHealthRef.current = nextEnemyHealth
|
setCombat({
|
||||||
setEnemyHealth(nextEnemyHealth)
|
...current,
|
||||||
|
party: nextParty,
|
||||||
|
resource: nextResource,
|
||||||
|
cooldowns: nextCooldowns,
|
||||||
|
elapsedTicks: nextElapsedTicks,
|
||||||
|
enemyHealth: nextEnemyHealth,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,8 +1000,14 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
||||||
enemyHealthRef.current = 0
|
setCombat({
|
||||||
setEnemyHealth(0)
|
...current,
|
||||||
|
party: nextParty,
|
||||||
|
resource: nextResource,
|
||||||
|
cooldowns: nextCooldowns,
|
||||||
|
elapsedTicks: nextElapsedTicks,
|
||||||
|
enemyHealth: 0,
|
||||||
|
})
|
||||||
setUpgradeChoices(chooseRandom(roguelikeUpgradeCatalog, 3))
|
setUpgradeChoices(chooseRandom(roguelikeUpgradeCatalog, 3))
|
||||||
setStatus('upgrade-choice')
|
setStatus('upgrade-choice')
|
||||||
addLog(`${encounter.enemyName} defeated. Choose an upgrade.`, 'loot')
|
addLog(`${encounter.enemyName} defeated. Choose an upgrade.`, 'loot')
|
||||||
@@ -925,16 +1015,28 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isPartBoss && !isFinalBoss) {
|
if (isPartBoss && !isFinalBoss) {
|
||||||
enemyHealthRef.current = 0
|
setCombat({
|
||||||
setEnemyHealth(0)
|
...current,
|
||||||
|
party: nextParty,
|
||||||
|
resource: nextResource,
|
||||||
|
cooldowns: nextCooldowns,
|
||||||
|
elapsedTicks: nextElapsedTicks,
|
||||||
|
enemyHealth: 0,
|
||||||
|
})
|
||||||
setStatus('part-complete')
|
setStatus('part-complete')
|
||||||
addLog(`${encounter.enemyName} is defeated.`, 'loot')
|
addLog(`${encounter.enemyName} is defeated.`, 'loot')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (encounterIndex === encounters.length - 1) {
|
if (encounterIndex === encounters.length - 1) {
|
||||||
enemyHealthRef.current = 0
|
setCombat({
|
||||||
setEnemyHealth(0)
|
...current,
|
||||||
|
party: nextParty,
|
||||||
|
resource: nextResource,
|
||||||
|
cooldowns: nextCooldowns,
|
||||||
|
elapsedTicks: nextElapsedTicks,
|
||||||
|
enemyHealth: 0,
|
||||||
|
})
|
||||||
finishRun(currentPart, startPart)
|
finishRun(currentPart, startPart)
|
||||||
addLog(`${encounter.enemyName} is defeated. Rolling its loot table.`, 'loot')
|
addLog(`${encounter.enemyName} is defeated. Rolling its loot table.`, 'loot')
|
||||||
return
|
return
|
||||||
@@ -952,20 +1054,20 @@ export function CombatScreen({
|
|||||||
maxHealthPenaltyTicks: undefined,
|
maxHealthPenaltyTicks: undefined,
|
||||||
healingReductionTicks: undefined,
|
healingReductionTicks: undefined,
|
||||||
}))
|
}))
|
||||||
partyRef.current = recoveredParty
|
|
||||||
enemyHealthRef.current = nextEncounter.maxHealth
|
|
||||||
setParty(recoveredParty)
|
|
||||||
setEncounterIndex((value) => value + 1)
|
setEncounterIndex((value) => value + 1)
|
||||||
setEnemyHealth(nextEncounter.maxHealth)
|
setCombat({
|
||||||
setElapsedTicks(0)
|
...current,
|
||||||
|
party: recoveredParty,
|
||||||
|
resource: nextResource,
|
||||||
|
cooldowns: nextCooldowns,
|
||||||
|
elapsedTicks: 0,
|
||||||
|
enemyHealth: nextEncounter.maxHealth,
|
||||||
|
})
|
||||||
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||||
}, TICK_MS)
|
|
||||||
return () => window.clearInterval(timer)
|
|
||||||
}, [
|
}, [
|
||||||
addLog,
|
addLog,
|
||||||
addFloatingHeal,
|
addFloatingHeal,
|
||||||
difficulty.damageMultiplier,
|
difficulty.damageMultiplier,
|
||||||
elapsedTicks,
|
|
||||||
encounter,
|
encounter,
|
||||||
encounterIndex,
|
encounterIndex,
|
||||||
encounters,
|
encounters,
|
||||||
@@ -981,12 +1083,45 @@ export function CombatScreen({
|
|||||||
gameClass.resourceName,
|
gameClass.resourceName,
|
||||||
requestLootRoll,
|
requestLootRoll,
|
||||||
profile.character.name,
|
profile.character.name,
|
||||||
|
setCombat,
|
||||||
startPart,
|
startPart,
|
||||||
status,
|
|
||||||
currentPart,
|
currentPart,
|
||||||
paused,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
runCombatTickRef.current = runCombatTick
|
||||||
|
}, [runCombatTick])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'playing' && !paused) {
|
||||||
|
if (!combatClockActiveRef.current) {
|
||||||
|
lastCombatTickAtRef.current = performance.now()
|
||||||
|
combatClockActiveRef.current = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
combatClockActiveRef.current = false
|
||||||
|
}, [paused, status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (
|
||||||
|
!combatClockActiveRef.current
|
||||||
|
|| statusRef.current !== 'playing'
|
||||||
|
|| pausedRef.current
|
||||||
|
) return
|
||||||
|
const now = performance.now()
|
||||||
|
const dueTicks = Math.min(4, Math.floor((now - lastCombatTickAtRef.current) / TICK_MS))
|
||||||
|
if (dueTicks <= 0) return
|
||||||
|
lastCombatTickAtRef.current += dueTicks * TICK_MS
|
||||||
|
for (let index = 0; index < dueTicks; index += 1) {
|
||||||
|
if (statusRef.current !== 'playing' || pausedRef.current) return
|
||||||
|
runCombatTickRef.current()
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!reward
|
!reward
|
||||||
@@ -1123,21 +1258,20 @@ export function CombatScreen({
|
|||||||
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`party-grid ${dungeon.partySize === 10 ? 'raid-party-grid' : ''}`}>
|
<div className={`party-grid ${dungeon.partySize >= 10 ? 'raid-party-grid' : ''}`}>
|
||||||
{party.map((member) => (
|
{party.map((member) => (
|
||||||
<button
|
<button
|
||||||
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
||||||
|
data-party-member-id={member.id}
|
||||||
key={member.id}
|
key={member.id}
|
||||||
onClick={() => setSelectedId(member.id)}
|
onClick={() => setSelectedTargetId(member.id)}
|
||||||
aria-pressed={selectedId === member.id}
|
aria-pressed={selectedId === member.id}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{selectedId === member.id && (
|
|
||||||
<span className="target-marker" aria-hidden="true">
|
<span className="target-marker" aria-hidden="true">
|
||||||
<i />
|
<i />
|
||||||
Target
|
Target
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
<div className="member-header">
|
<div className="member-header">
|
||||||
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
||||||
<strong>{member.name}</strong>
|
<strong>{member.name}</strong>
|
||||||
@@ -1146,6 +1280,7 @@ export function CombatScreen({
|
|||||||
<div className="bar member-health">
|
<div className="bar member-health">
|
||||||
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
||||||
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
||||||
|
<em className="health-text">{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</em>
|
||||||
</div>
|
</div>
|
||||||
<div className="floating-combat-texts" aria-hidden="true">
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
{floatingTexts
|
{floatingTexts
|
||||||
@@ -1212,7 +1347,7 @@ export function CombatScreen({
|
|||||||
{dualScreenEnabled && (
|
{dualScreenEnabled && (
|
||||||
<DualScreenTopCombat
|
<DualScreenTopCombat
|
||||||
state={dualScreenState}
|
state={dualScreenState}
|
||||||
onSelectTarget={setSelectedId}
|
onSelectTarget={setSelectedTargetId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1232,7 +1367,7 @@ export function CombatScreen({
|
|||||||
|
|
||||||
{status === 'upgrade-choice' && (
|
{status === 'upgrade-choice' && (
|
||||||
<div className="result-screen">
|
<div className="result-screen">
|
||||||
<div>
|
<div className="pvp-upgrade-dialog pve-upgrade-dialog">
|
||||||
<p className="eyebrow">
|
<p className="eyebrow">
|
||||||
{encounter.isBoss
|
{encounter.isBoss
|
||||||
? `Roguelike Stage ${roguelikeStage} Complete`
|
? `Roguelike Stage ${roguelikeStage} Complete`
|
||||||
@@ -1240,6 +1375,9 @@ export function CombatScreen({
|
|||||||
</p>
|
</p>
|
||||||
<h2>Choose Upgrade</h2>
|
<h2>Choose Upgrade</h2>
|
||||||
<p>Pick one upgrade before the next fight.</p>
|
<p>Pick one upgrade before the next fight.</p>
|
||||||
|
<div className="pvp-choice-columns">
|
||||||
|
<div>
|
||||||
|
<strong>Run Buff</strong>
|
||||||
<div className="upgrade-choice-grid">
|
<div className="upgrade-choice-grid">
|
||||||
{upgradeChoices.map((upgrade) => (
|
{upgradeChoices.map((upgrade) => (
|
||||||
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
|
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
|
||||||
@@ -1248,6 +1386,8 @@ export function CombatScreen({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{roguelikeUpgrades.length > 0 && (
|
{roguelikeUpgrades.length > 0 && (
|
||||||
<p className="roguelike-upgrade-list">
|
<p className="roguelike-upgrade-list">
|
||||||
Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}
|
Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}
|
||||||
@@ -1314,7 +1454,7 @@ export function CombatScreen({
|
|||||||
<div className="bonus-item-detail">
|
<div className="bonus-item-detail">
|
||||||
<span>{reward.bonusItem.glyph}</span>
|
<span>{reward.bonusItem.glyph}</span>
|
||||||
<strong className={`rarity-${reward.bonusItem.rarity}`}>{reward.bonusItem.name}</strong>
|
<strong className={`rarity-${reward.bonusItem.rarity}`}>{reward.bonusItem.name}</strong>
|
||||||
<small>Item Level {reward.bonusItem.itemLevel}</small>
|
<small>Item Level {reward.bonusItem.itemLevel} x{reward.bonusItem.quantity}</small>
|
||||||
{reward.bonusItem.duplicate && <small> (owned x{reward.bonusItem.quantityAfter})</small>}
|
{reward.bonusItem.duplicate && <small> (owned x{reward.bonusItem.quantityAfter})</small>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1384,18 +1524,20 @@ export function CombatScreen({
|
|||||||
const nextIndex = encounterIndex + 1
|
const nextIndex = encounterIndex + 1
|
||||||
partStartTimesRef.current[currentPart + 1] = Date.now()
|
partStartTimesRef.current[currentPart + 1] = Date.now()
|
||||||
const nextEncounter = encounters[nextIndex]
|
const nextEncounter = encounters[nextIndex]
|
||||||
const recoveredParty = partyRef.current.map((member) => ({
|
const current = combatRef.current
|
||||||
|
const recoveredParty = current.party.map((member) => ({
|
||||||
...member,
|
...member,
|
||||||
health: clamp(member.health + 35, 0, member.maxHealth),
|
health: clamp(member.health + 35, 0, member.maxHealth),
|
||||||
debuff: undefined,
|
debuff: undefined,
|
||||||
debuffTicks: undefined,
|
debuffTicks: undefined,
|
||||||
}))
|
}))
|
||||||
partyRef.current = recoveredParty
|
|
||||||
enemyHealthRef.current = nextEncounter.maxHealth
|
|
||||||
setParty(recoveredParty)
|
|
||||||
setEncounterIndex(nextIndex)
|
setEncounterIndex(nextIndex)
|
||||||
setEnemyHealth(nextEncounter.maxHealth)
|
setCombat({
|
||||||
setElapsedTicks(0)
|
...current,
|
||||||
|
party: recoveredParty,
|
||||||
|
enemyHealth: nextEncounter.maxHealth,
|
||||||
|
elapsedTicks: 0,
|
||||||
|
})
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
|
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
type GameClass,
|
type GameClass,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
import { EquipmentScreen } from './EquipmentScreen'
|
import { EquipmentScreen } from './EquipmentScreen'
|
||||||
import { TalentScreen } from './TalentScreen'
|
import { TalentScreen } from './TalentScreen'
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||||
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class')
|
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [classId, setClassId] = useState(profile.character.classId)
|
const [classId, setClassId] = useState(profile.character.classId)
|
||||||
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
||||||
const [selectedSlot, setSelectedSlot] = useState(0)
|
const [selectedSlot, setSelectedSlot] = useState(0)
|
||||||
@@ -63,6 +65,29 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||||
|
if (activeTab !== 'class') return null
|
||||||
|
return {
|
||||||
|
mode: 'class',
|
||||||
|
title: 'Ability Library',
|
||||||
|
subtitle: gameClass.name,
|
||||||
|
summary: `Selected slot ${selectedSlot + 1}. ${message || 'Choose an ability for the active loadout.'}`,
|
||||||
|
items: gameClass.spells.map((ability) => {
|
||||||
|
const locked = ability.unlockLevel > profile.character.level
|
||||||
|
const equipped = slots.includes(ability.id)
|
||||||
|
return {
|
||||||
|
glyph: locked ? 'L' : ability.glyph,
|
||||||
|
title: ability.name,
|
||||||
|
meta: locked ? `Level ${ability.unlockLevel}` : `${ability.cost} ${gameClass.resourceName}`,
|
||||||
|
detail: ability.description,
|
||||||
|
status: equipped ? 'Equipped' : locked ? 'Locked' : '',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}, [activeTab, gameClass, message, profile.character.level, selectedSlot, slots])
|
||||||
|
|
||||||
|
useDualScreenWorkshopPublisher(classWorkshopState, dualScreenEnabled)
|
||||||
|
|
||||||
async function persistChanges() {
|
async function persistChanges() {
|
||||||
saveScroll()
|
saveScroll()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -80,7 +105,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="content-screen customize-screen">
|
<section className="content-screen customize-screen">
|
||||||
<div className="screen-heading">
|
<div className="screen-heading customize-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Character Workshop</p>
|
<p className="eyebrow">Character Workshop</p>
|
||||||
<h1>Customize Character</h1>
|
<h1>Customize Character</h1>
|
||||||
@@ -89,8 +114,10 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
||||||
|
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
|
||||||
{([
|
{([
|
||||||
{ key: 'equipment', label: 'Equipment' },
|
{ key: 'equipment', label: 'Equipment' },
|
||||||
|
{ key: 'crafting', label: 'Crafting' },
|
||||||
{ key: 'talents', label: 'Talents' },
|
{ key: 'talents', label: 'Talents' },
|
||||||
{ key: 'class', label: 'Class' },
|
{ key: 'class', label: 'Class' },
|
||||||
] as const).map((tab) => (
|
] as const).map((tab) => (
|
||||||
@@ -110,6 +137,18 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
{activeTab === 'equipment' && (
|
{activeTab === 'equipment' && (
|
||||||
<EquipmentScreen
|
<EquipmentScreen
|
||||||
embedded
|
embedded
|
||||||
|
mode="equipment"
|
||||||
|
showModeTabs={false}
|
||||||
|
profile={profile}
|
||||||
|
onUpdated={onSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'crafting' && (
|
||||||
|
<EquipmentScreen
|
||||||
|
embedded
|
||||||
|
mode="crafting"
|
||||||
|
showModeTabs={false}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
onUpdated={onSaved}
|
onUpdated={onSaved}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
craftItem,
|
craftItem,
|
||||||
equipItem,
|
equipItem,
|
||||||
loadProfile,
|
loadProfile,
|
||||||
|
upgradeItem,
|
||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
type EquipmentSlot,
|
type EquipmentSlot,
|
||||||
type Item,
|
type Item,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
|
|
||||||
const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
||||||
weapon: 'Weapon',
|
weapon: 'Weapon',
|
||||||
@@ -22,14 +24,29 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
|||||||
component: 'Component',
|
component: 'Component',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EQUIPMENT_LIST_PAGE_SIZE = 3
|
||||||
|
const CRAFTING_LIST_PAGE_SIZE = 3
|
||||||
|
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
|
||||||
|
.filter((slot) => slot !== 'component')
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
onUpdated: (profile: CharacterProfile) => void
|
onUpdated: (profile: CharacterProfile) => void
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
|
mode?: 'equipment' | 'crafting'
|
||||||
|
showModeTabs?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
export function EquipmentScreen({
|
||||||
|
profile,
|
||||||
|
onBack,
|
||||||
|
onUpdated,
|
||||||
|
embedded = false,
|
||||||
|
mode,
|
||||||
|
showModeTabs = true,
|
||||||
|
}: Props) {
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const totalItemCount = profile.inventory.reduce(
|
const totalItemCount = profile.inventory.reduce(
|
||||||
(total, item) => total + item.quantity,
|
(total, item) => total + item.quantity,
|
||||||
0,
|
0,
|
||||||
@@ -43,8 +60,11 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
const [equipping, setEquipping] = useState(false)
|
const [equipping, setEquipping] = useState(false)
|
||||||
const [breakingDown, setBreakingDown] = useState(false)
|
const [breakingDown, setBreakingDown] = useState(false)
|
||||||
const [crafting, setCrafting] = useState(false)
|
const [crafting, setCrafting] = useState(false)
|
||||||
|
const [upgrading, setUpgrading] = useState(false)
|
||||||
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
||||||
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
|
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
|
||||||
|
const [inventoryPage, setInventoryPage] = useState(0)
|
||||||
|
const [recipePage, setRecipePage] = useState(0)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const scrollRef = useRef<number>(0)
|
const scrollRef = useRef<number>(0)
|
||||||
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
|
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
|
||||||
@@ -54,6 +74,25 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
firstRecipe?.id ?? null,
|
firstRecipe?.id ?? null,
|
||||||
)
|
)
|
||||||
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
||||||
|
const selectedRecipeRequiresUpgrade = selectedRecipe
|
||||||
|
? profile.craftingRecipes.some((recipe) =>
|
||||||
|
recipe.sourceEncounterId === selectedRecipe.sourceEncounterId
|
||||||
|
&& recipe.item.slot === selectedRecipe.item.slot
|
||||||
|
&& recipe.item.itemLevel < selectedRecipe.item.itemLevel,
|
||||||
|
)
|
||||||
|
: false
|
||||||
|
const selectedItemRecipe = selectedItem
|
||||||
|
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||||
|
: undefined
|
||||||
|
const upgradeRecipe = selectedItem && selectedItemRecipe
|
||||||
|
? profile.craftingRecipes
|
||||||
|
.filter((recipe) =>
|
||||||
|
recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId
|
||||||
|
&& recipe.item.slot === selectedItem.slot
|
||||||
|
&& recipe.item.itemLevel > selectedItem.itemLevel,
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0]
|
||||||
|
: undefined
|
||||||
const equippedBySlot = useMemo(
|
const equippedBySlot = useMemo(
|
||||||
() => new Map(
|
() => new Map(
|
||||||
profile.inventory
|
profile.inventory
|
||||||
@@ -75,6 +114,14 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
(total, item) => total + item.quantity,
|
(total, item) => total + item.quantity,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
const inventoryPageCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(visibleInventory.length / EQUIPMENT_LIST_PAGE_SIZE),
|
||||||
|
)
|
||||||
|
const inventoryPageItems = visibleInventory.slice(
|
||||||
|
inventoryPage * EQUIPMENT_LIST_PAGE_SIZE,
|
||||||
|
(inventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
||||||
const [levelFilter, setLevelFilter] = useState<number | null>(null)
|
const [levelFilter, setLevelFilter] = useState<number | null>(null)
|
||||||
@@ -92,17 +139,57 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
},
|
},
|
||||||
[profile.craftingRecipes, slotFilter, levelFilter],
|
[profile.craftingRecipes, slotFilter, levelFilter],
|
||||||
)
|
)
|
||||||
|
const readyRecipeCount = filteredRecipes.filter((recipe) => recipe.canCraft).length
|
||||||
|
const slotRecipeCounts = useMemo(
|
||||||
|
() => new Map(
|
||||||
|
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
|
||||||
|
slot,
|
||||||
|
profile.craftingRecipes.filter((recipe) => recipe.item.slot === slot).length,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
[profile.craftingRecipes],
|
||||||
|
)
|
||||||
|
const recipePageCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
|
||||||
|
)
|
||||||
|
const recipePageItems = filteredRecipes.slice(
|
||||||
|
recipePage * CRAFTING_LIST_PAGE_SIZE,
|
||||||
|
(recipePage + 1) * CRAFTING_LIST_PAGE_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.scrollTo(0, scrollRef.current)
|
window.scrollTo(0, scrollRef.current)
|
||||||
}, [profile])
|
}, [profile])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInventoryPage((current) => Math.min(current, inventoryPageCount - 1))
|
||||||
|
}, [inventoryPageCount])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRecipePage((current) => Math.min(current, recipePageCount - 1))
|
||||||
|
}, [recipePageCount])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredRecipes.length === 0) {
|
||||||
|
setSelectedRecipeId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)) {
|
||||||
|
setSelectedRecipeId(filteredRecipes[0].id)
|
||||||
|
}
|
||||||
|
}, [filteredRecipes, selectedRecipeId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (equipmentTab === 'crafting') {
|
if (equipmentTab === 'crafting') {
|
||||||
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
|
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [equipmentTab])
|
}, [equipmentTab])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode) setEquipmentTab(mode)
|
||||||
|
}, [mode])
|
||||||
|
|
||||||
function saveScroll() {
|
function saveScroll() {
|
||||||
scrollRef.current = window.scrollY
|
scrollRef.current = window.scrollY
|
||||||
}
|
}
|
||||||
@@ -160,6 +247,160 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function upgradeSelected() {
|
||||||
|
if (!selectedItem || !upgradeRecipe) return
|
||||||
|
saveScroll()
|
||||||
|
setUpgrading(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
const updated = await upgradeItem(selectedItem.id)
|
||||||
|
onUpdated(updated)
|
||||||
|
setSelectedItemId(upgradeRecipe.item.id)
|
||||||
|
setMessage(`${selectedItem.name} upgraded to ${upgradeRecipe.item.name}.`)
|
||||||
|
} catch (reason) {
|
||||||
|
setMessage(reason instanceof Error ? reason.message : 'Unable to upgrade item.')
|
||||||
|
} finally {
|
||||||
|
setUpgrading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEquipmentActions() {
|
||||||
|
if (!selectedItem) {
|
||||||
|
return <p>Select an item to inspect it.</p>
|
||||||
|
}
|
||||||
|
if (selectedItem.slot === 'component') {
|
||||||
|
return <p className="component-note">Used in crafting.</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
|
||||||
|
onClick={equipSelected}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
|
||||||
|
</button>
|
||||||
|
{upgradeRecipe && (
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
|
||||||
|
onClick={upgradeSelected}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
|
||||||
|
<button
|
||||||
|
className="breakdown-button"
|
||||||
|
disabled={equipping || breakingDown || upgrading}
|
||||||
|
onClick={breakdownSelected}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{breakingDown
|
||||||
|
? 'Breaking Down...'
|
||||||
|
: selectedItem.quantity > 1
|
||||||
|
? 'Break Down Duplicate'
|
||||||
|
: 'Break Down'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workshopState = useMemo<DualScreenWorkshopState>(() => {
|
||||||
|
if (equipmentTab === 'crafting') {
|
||||||
|
if (!selectedRecipe) {
|
||||||
|
return {
|
||||||
|
mode: 'crafting',
|
||||||
|
title: 'Craft Output',
|
||||||
|
subtitle: 'No recipe selected',
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: 'crafting',
|
||||||
|
title: selectedRecipe.item.name,
|
||||||
|
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
|
||||||
|
summary: selectedRecipe.item.description,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
glyph: selectedRecipe.item.glyph,
|
||||||
|
title: 'Craft Output',
|
||||||
|
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
|
||||||
|
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
|
||||||
|
},
|
||||||
|
...selectedRecipe.components.map((component) => ({
|
||||||
|
glyph: component.item.glyph,
|
||||||
|
title: component.item.name,
|
||||||
|
meta: `Item Level ${component.item.itemLevel}`,
|
||||||
|
status: `${component.owned}/${component.quantity}`,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!selectedItem) {
|
||||||
|
return {
|
||||||
|
mode: 'equipment',
|
||||||
|
title: 'Equipment Detail',
|
||||||
|
subtitle: 'No item selected',
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: 'equipment',
|
||||||
|
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
|
||||||
|
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
|
||||||
|
summary: selectedItem.description,
|
||||||
|
items: selectedItem.slot === 'component'
|
||||||
|
? [{
|
||||||
|
glyph: selectedItem.glyph,
|
||||||
|
title: selectedItem.name,
|
||||||
|
meta: `Owned: ${selectedItem.quantity}`,
|
||||||
|
status: 'Component',
|
||||||
|
}]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
glyph: selectedItem.glyph,
|
||||||
|
title: selectedItem.name,
|
||||||
|
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
|
||||||
|
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
|
||||||
|
},
|
||||||
|
...(comparisonItem && comparisonItem.id !== selectedItem.id
|
||||||
|
? [{
|
||||||
|
glyph: comparisonItem.glyph,
|
||||||
|
title: comparisonItem.name,
|
||||||
|
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
|
||||||
|
status: 'Currently Equipped',
|
||||||
|
}]
|
||||||
|
: [{
|
||||||
|
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
|
||||||
|
status: 'Comparison',
|
||||||
|
}]),
|
||||||
|
...(upgradeRecipe
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
glyph: upgradeRecipe.item.glyph,
|
||||||
|
title: `Upgrade to ${upgradeRecipe.item.name}`,
|
||||||
|
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
|
||||||
|
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
|
||||||
|
},
|
||||||
|
...upgradeRecipe.components.map((component) => ({
|
||||||
|
glyph: component.item.glyph,
|
||||||
|
title: component.item.name,
|
||||||
|
meta: `Required for upgrade`,
|
||||||
|
status: `${component.owned}/${component.quantity}`,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
|
||||||
|
|
||||||
|
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
@@ -186,6 +427,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
|
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showModeTabs && (
|
||||||
<nav className="equipment-tabs">
|
<nav className="equipment-tabs">
|
||||||
<button
|
<button
|
||||||
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
||||||
@@ -202,6 +444,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
Crafting
|
Crafting
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
{equipmentTab === 'equipment' ? (
|
{equipmentTab === 'equipment' ? (
|
||||||
<>
|
<>
|
||||||
@@ -210,9 +453,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
selectedItem.slot === 'component' ? (
|
selectedItem.slot === 'component' ? (
|
||||||
<>
|
<>
|
||||||
<ItemDetail title="Crafting Component" item={selectedItem} />
|
<ItemDetail title="Crafting Component" item={selectedItem} />
|
||||||
<div className="equip-action">
|
|
||||||
<p className="component-note">Used in crafting.</p>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -226,31 +466,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
|
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="equip-action">
|
|
||||||
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
disabled={selectedItem.equipped || equipping || breakingDown}
|
|
||||||
onClick={equipSelected}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
|
|
||||||
</button>
|
|
||||||
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
|
|
||||||
<button
|
|
||||||
className="breakdown-button"
|
|
||||||
disabled={equipping || breakingDown}
|
|
||||||
onClick={breakdownSelected}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{breakingDown
|
|
||||||
? 'Breaking Down...'
|
|
||||||
: selectedItem.quantity > 1
|
|
||||||
? 'Break Down Duplicate'
|
|
||||||
: 'Break Down'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -258,6 +473,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="equipment-action-strip">
|
||||||
|
{renderEquipmentActions()}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="equipment-layout">
|
<div className="equipment-layout">
|
||||||
<section className="equipped-panel">
|
<section className="equipped-panel">
|
||||||
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
|
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
|
||||||
@@ -270,6 +489,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
key={slot}
|
key={slot}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedSlot(slot)
|
setSelectedSlot(slot)
|
||||||
|
setInventoryPage(0)
|
||||||
const firstSlotItem = profile.inventory.find(
|
const firstSlotItem = profile.inventory.find(
|
||||||
(candidate) => candidate.slot === slot,
|
(candidate) => candidate.slot === slot,
|
||||||
)
|
)
|
||||||
@@ -302,14 +522,17 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
{selectedSlot && (
|
{selectedSlot && (
|
||||||
<button
|
<button
|
||||||
className="inventory-filter-clear"
|
className="inventory-filter-clear"
|
||||||
onClick={() => setSelectedSlot(null)}
|
onClick={() => {
|
||||||
|
setSelectedSlot(null)
|
||||||
|
setInventoryPage(0)
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Show All Items
|
Show All Items
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="inventory-list">
|
<div className="inventory-list">
|
||||||
{visibleInventory.map((item) => (
|
{inventoryPageItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
className={`${selectedItemId === item.id ? 'selected' : ''} rarity-${item.rarity}`}
|
className={`${selectedItemId === item.id ? 'selected' : ''} rarity-${item.rarity}`}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -333,6 +556,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{visibleInventory.length > EQUIPMENT_LIST_PAGE_SIZE && (
|
||||||
|
<ListPager
|
||||||
|
label={`Page ${inventoryPage + 1} / ${inventoryPageCount}`}
|
||||||
|
onNext={() => setInventoryPage((current) => Math.min(inventoryPageCount - 1, current + 1))}
|
||||||
|
onPrevious={() => setInventoryPage((current) => Math.max(0, current - 1))}
|
||||||
|
nextDisabled={inventoryPage >= inventoryPageCount - 1}
|
||||||
|
previousDisabled={inventoryPage <= 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -340,38 +572,83 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
<section className="crafting-panel">
|
<section className="crafting-panel">
|
||||||
<EquipmentHeading
|
<EquipmentHeading
|
||||||
eyebrow="Crafting"
|
eyebrow="Crafting"
|
||||||
title="Recipes"
|
title="Workbench"
|
||||||
detail={`${filteredRecipes.filter((recipe) => recipe.canCraft).length} ready`}
|
detail={`${readyRecipeCount} ready / ${filteredRecipes.length} shown`}
|
||||||
/>
|
/>
|
||||||
<div className="crafting-filter-bar">
|
|
||||||
<select
|
|
||||||
className="filter-select"
|
|
||||||
value={slotFilter}
|
|
||||||
onChange={(e) => setSlotFilter(e.target.value as EquipmentSlot | 'all')}
|
|
||||||
>
|
|
||||||
<option value="all">All Slots</option>
|
|
||||||
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
|
|
||||||
<option key={slot} value={slot}>{label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="filter-select"
|
|
||||||
value={levelFilter ?? ''}
|
|
||||||
onChange={(e) => setLevelFilter(e.target.value === '' ? null : Number(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="">All Levels</option>
|
|
||||||
{availableLevels.map((level) => (
|
|
||||||
<option key={level} value={level}>Item Level {level}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{filteredRecipes.length === 0 && (
|
|
||||||
<p className="inventory-empty">No crafting recipes match filters.</p>
|
|
||||||
)}
|
|
||||||
{filteredRecipes.length > 0 && (
|
|
||||||
<div className="crafting-layout">
|
<div className="crafting-layout">
|
||||||
|
<aside className="crafting-filters">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Slot</p>
|
||||||
|
<div className="crafting-filter-grid">
|
||||||
|
<button
|
||||||
|
className={slotFilter === 'all' ? 'active' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setSlotFilter('all')
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<strong>All</strong>
|
||||||
|
<span>{profile.craftingRecipes.length}</span>
|
||||||
|
</button>
|
||||||
|
{CRAFTING_FILTER_SLOTS.map((slot) => (
|
||||||
|
<button
|
||||||
|
className={slotFilter === slot ? 'active' : ''}
|
||||||
|
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
|
||||||
|
key={slot}
|
||||||
|
onClick={() => {
|
||||||
|
setSlotFilter(slot)
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<strong>{SLOT_LABELS[slot]}</strong>
|
||||||
|
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Item Level</p>
|
||||||
|
<div className="crafting-level-row">
|
||||||
|
<button
|
||||||
|
className={levelFilter === null ? 'active' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setLevelFilter(null)
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{availableLevels.map((level) => (
|
||||||
|
<button
|
||||||
|
className={levelFilter === level ? 'active' : ''}
|
||||||
|
key={level}
|
||||||
|
onClick={() => {
|
||||||
|
setLevelFilter(level)
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{level}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="crafting-list-panel">
|
||||||
|
<EquipmentHeading
|
||||||
|
eyebrow="Recipes"
|
||||||
|
title={slotFilter === 'all' ? 'Available' : SLOT_LABELS[slotFilter]}
|
||||||
|
detail={`Page ${recipePage + 1}/${recipePageCount}`}
|
||||||
|
/>
|
||||||
|
{filteredRecipes.length === 0 ? (
|
||||||
|
<p className="inventory-empty">No recipes match filters.</p>
|
||||||
|
) : (
|
||||||
<div className="crafting-list">
|
<div className="crafting-list">
|
||||||
{filteredRecipes.map((recipe) => (
|
{recipePageItems.map((recipe) => (
|
||||||
<button
|
<button
|
||||||
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
|
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
|
||||||
key={recipe.id}
|
key={recipe.id}
|
||||||
@@ -386,13 +663,42 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
|
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i>
|
<i className={recipe.canCraft ? 'ready' : 'missing'}>
|
||||||
|
{recipe.canCraft ? 'Ready' : 'Needs materials'}
|
||||||
|
</i>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{selectedRecipe && (
|
)}
|
||||||
|
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
|
||||||
|
<ListPager
|
||||||
|
label={`Page ${recipePage + 1} / ${recipePageCount}`}
|
||||||
|
onNext={() => setRecipePage((current) => Math.min(recipePageCount - 1, current + 1))}
|
||||||
|
onPrevious={() => setRecipePage((current) => Math.max(0, current - 1))}
|
||||||
|
nextDisabled={recipePage >= recipePageCount - 1}
|
||||||
|
previousDisabled={recipePage <= 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="crafting-action-row">
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
|
||||||
|
onClick={craftSelected}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="crafting-detail-panel">
|
||||||
|
{selectedRecipe ? (
|
||||||
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
|
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
|
||||||
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
|
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
|
||||||
|
<div className="crafting-detail-heading">
|
||||||
|
<p className="eyebrow">Materials</p>
|
||||||
|
<span>{selectedRecipe.canCraft ? 'Ready' : 'Missing components'}</span>
|
||||||
|
</div>
|
||||||
<div className="crafting-components">
|
<div className="crafting-components">
|
||||||
{selectedRecipe.components.map((component) => (
|
{selectedRecipe.components.map((component) => (
|
||||||
<div
|
<div
|
||||||
@@ -405,22 +711,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
disabled={!selectedRecipe.canCraft || crafting}
|
|
||||||
onClick={craftSelected}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{crafting ? 'Crafting...' : 'Craft Item'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="inventory-empty">Select a recipe.</p>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{profile.setBonuses.length > 0 && (
|
{equipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
|
||||||
<section className="set-bonus-panel">
|
<section className="set-bonus-panel">
|
||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -456,16 +756,38 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
return <div className="equipment-screen embedded-screen">{content}</div>
|
return <div className={`equipment-screen embedded-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="content-screen equipment-screen">
|
<section className={`content-screen equipment-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
|
||||||
{content}
|
{content}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ListPager({
|
||||||
|
label,
|
||||||
|
nextDisabled,
|
||||||
|
previousDisabled,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
nextDisabled: boolean
|
||||||
|
previousDisabled: boolean
|
||||||
|
onNext: () => void
|
||||||
|
onPrevious: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="list-pager">
|
||||||
|
<button disabled={previousDisabled} onClick={onPrevious} type="button">Prev</button>
|
||||||
|
<span>{label}</span>
|
||||||
|
<button disabled={nextDisabled} onClick={onNext} type="button">Next</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function GearStat({ value, label }: { value: string; label: string }) {
|
function GearStat({ value, label }: { value: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="gear-stat">
|
<div className="gear-stat">
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { INITIAL_PARTY, RAID_PARTY, type CombatLogEntry, type PartyMember, type Spell } from '../game'
|
import {
|
||||||
|
INITIAL_PARTY,
|
||||||
|
RAID_PARTY,
|
||||||
|
DEFAULT_GROUP_HEAL_TARGETS,
|
||||||
|
groupHealTargets,
|
||||||
|
partyDamageOutput,
|
||||||
|
tankPressureTargets,
|
||||||
|
type CombatLogEntry,
|
||||||
|
type PartyMember,
|
||||||
|
type Spell,
|
||||||
|
} from '../game'
|
||||||
import { completeRoguelike, type DungeonReward } from '../profile'
|
import { completeRoguelike, type DungeonReward } from '../profile'
|
||||||
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
|
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
|
||||||
import type { GameMode } from '../gameRepository'
|
import type { GameMode } from '../gameRepository'
|
||||||
import { ControllerBindingLabel } from './ControllerIcons'
|
import { ControllerBindingLabel } from './ControllerIcons'
|
||||||
import { useGameAction, useInput, type InputAction } from '../input'
|
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
|
||||||
import {
|
import {
|
||||||
|
DualScreenTopCombat,
|
||||||
|
useDualScreen,
|
||||||
|
useDualScreenPublisher,
|
||||||
|
type DualScreenCombatState,
|
||||||
|
} from '../dualScreen'
|
||||||
|
import {
|
||||||
|
loadPvpRoguelikeCheckpoint,
|
||||||
randomCpuDifficulty,
|
randomCpuDifficulty,
|
||||||
recordCpuPvpLeaderboard,
|
recordCpuPvpLeaderboard,
|
||||||
|
recordPvpRoguelikeCheckpoint,
|
||||||
type CpuDifficulty,
|
type CpuDifficulty,
|
||||||
type PvpContentType,
|
type PvpContentType,
|
||||||
} from '../pvpRoguelike'
|
} from '../pvpRoguelike'
|
||||||
@@ -23,6 +41,7 @@ type BossMechanic =
|
|||||||
|
|
||||||
type PvpEncounter = DungeonEncounter & {
|
type PvpEncounter = DungeonEncounter & {
|
||||||
bossMechanics?: BossMechanic[]
|
bossMechanics?: BossMechanic[]
|
||||||
|
sourceEncounterId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
||||||
@@ -69,6 +88,17 @@ type FloatingCombatText = {
|
|||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PvpRunSummary = {
|
||||||
|
bossesKilled: number
|
||||||
|
experienceGained: number
|
||||||
|
previousLevel: number | null
|
||||||
|
newLevel: number | null
|
||||||
|
levelsGained: number
|
||||||
|
talentPointsGained: number
|
||||||
|
unlockedAbilities: DungeonReward['unlockedAbilities']
|
||||||
|
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
||||||
|
}
|
||||||
|
|
||||||
const BOSS_MECHANICS: BossMechanic[] = [
|
const BOSS_MECHANICS: BossMechanic[] = [
|
||||||
'party-pulse',
|
'party-pulse',
|
||||||
'searing-mark',
|
'searing-mark',
|
||||||
@@ -110,6 +140,19 @@ function formatEffectTime(ticks: number) {
|
|||||||
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
|
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createEmptyPvpRunSummary(): PvpRunSummary {
|
||||||
|
return {
|
||||||
|
bossesKilled: 0,
|
||||||
|
experienceGained: 0,
|
||||||
|
previousLevel: null,
|
||||||
|
newLevel: null,
|
||||||
|
levelsGained: 0,
|
||||||
|
talentPointsGained: 0,
|
||||||
|
unlockedAbilities: [],
|
||||||
|
loot: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buffStacks<T extends string>(items: T[], id: T) {
|
function buffStacks<T extends string>(items: T[], id: T) {
|
||||||
return items.filter((item) => item === id).length
|
return items.filter((item) => item === id).length
|
||||||
}
|
}
|
||||||
@@ -238,7 +281,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
|
|||||||
encounter.maxHealth
|
encounter.maxHealth
|
||||||
+ encounter.damage * 18
|
+ encounter.damage * 18
|
||||||
+ encounter.tankDamage * 10
|
+ encounter.tankDamage * 10
|
||||||
+ encounter.partyDamage * 12
|
+ encounter.partyDamage * 18
|
||||||
)
|
)
|
||||||
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
|
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
|
||||||
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
||||||
@@ -255,6 +298,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
|
|||||||
const isBoss = index === 2
|
const isBoss = index === 2
|
||||||
return {
|
return {
|
||||||
...encounter,
|
...encounter,
|
||||||
|
sourceEncounterId: encounter.id,
|
||||||
id: 910000 + stage * 10 + index,
|
id: 910000 + stage * 10 + index,
|
||||||
sequence: (stage - 1) * 3 + index + 1,
|
sequence: (stage - 1) * 3 + index + 1,
|
||||||
isBoss,
|
isBoss,
|
||||||
@@ -366,7 +410,7 @@ export function PvPRoguelikeScreen({
|
|||||||
.filter((spell) => spell.unlockLevel === 1)
|
.filter((spell) => spell.unlockLevel === 1)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
||||||
const [abilityLabelMode, setAbilityLabelMode] = useState<AbilityLabelMode>('ability')
|
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
|
||||||
const selfBuffChoicesCatalog = useMemo(
|
const selfBuffChoicesCatalog = useMemo(
|
||||||
() => buildSelfBuffChoices(starterSpells, abilityLabelMode),
|
() => buildSelfBuffChoices(starterSpells, abilityLabelMode),
|
||||||
[abilityLabelMode, starterSpells],
|
[abilityLabelMode, starterSpells],
|
||||||
@@ -375,6 +419,10 @@ export function PvPRoguelikeScreen({
|
|||||||
() => buildOpponentDebuffChoices(starterSpells, abilityLabelMode),
|
() => buildOpponentDebuffChoices(starterSpells, abilityLabelMode),
|
||||||
[abilityLabelMode, starterSpells],
|
[abilityLabelMode, starterSpells],
|
||||||
)
|
)
|
||||||
|
const [checkpointStage, setCheckpointStage] = useState(() =>
|
||||||
|
loadPvpRoguelikeCheckpoint(profile.character.id, contentType),
|
||||||
|
)
|
||||||
|
const [startStage, setStartStage] = useState(checkpointStage)
|
||||||
const maxResource = gameClass.maxResource
|
const maxResource = gameClass.maxResource
|
||||||
const partyTemplate = useMemo(
|
const partyTemplate = useMemo(
|
||||||
() => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
() => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
||||||
@@ -391,17 +439,19 @@ export function PvPRoguelikeScreen({
|
|||||||
[contentType],
|
[contentType],
|
||||||
)
|
)
|
||||||
const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
|
const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
|
||||||
const [stage, setStage] = useState(1)
|
const [stage, setStage] = useState(startStage)
|
||||||
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, 1, contentType))
|
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
|
||||||
const [encounterIndex, setEncounterIndex] = useState(0)
|
const [encounterIndex, setEncounterIndex] = useState(0)
|
||||||
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
|
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
|
||||||
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
||||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||||
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||||
const [elapsedTicks, setElapsedTicks] = useState(0)
|
const [elapsedTicks, setElapsedTicks] = useState(0)
|
||||||
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
||||||
const [queueMessage, setQueueMessage] = useState('')
|
const [queueMessage, setQueueMessage] = useState('')
|
||||||
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
||||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||||
|
const [runSummary, setRunSummary] = useState<PvpRunSummary>(() => createEmptyPvpRunSummary())
|
||||||
const [rewardError, setRewardError] = useState('')
|
const [rewardError, setRewardError] = useState('')
|
||||||
const [showEndLog, setShowEndLog] = useState(false)
|
const [showEndLog, setShowEndLog] = useState(false)
|
||||||
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
||||||
@@ -410,11 +460,17 @@ export function PvPRoguelikeScreen({
|
|||||||
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
||||||
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
||||||
const [encountersCleared, setEncountersCleared] = useState(0)
|
const [encountersCleared, setEncountersCleared] = useState(0)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
|
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||||
const nextLogId = useRef(2)
|
const nextLogId = useRef(2)
|
||||||
const nextFloatingTextId = useRef(1)
|
const nextFloatingTextId = useRef(1)
|
||||||
const recordedRunRef = useRef(false)
|
const recordedRunRef = useRef(false)
|
||||||
const rewardClaimedRef = useRef(false)
|
const rewardClaimedRef = useRef(false)
|
||||||
|
const bossRewardClaimedRef = useRef(new Set<number>())
|
||||||
|
const cpuDefeatedRef = useRef(false)
|
||||||
const playerClearedEncounterRef = useRef(-1)
|
const playerClearedEncounterRef = useRef(-1)
|
||||||
|
const queuedMatchRef = useRef(false)
|
||||||
|
const encounterPoolRef = useRef(encounterPool)
|
||||||
const playerRef = useRef(playerSide)
|
const playerRef = useRef(playerSide)
|
||||||
const cpuRef = useRef(cpuSide)
|
const cpuRef = useRef(cpuSide)
|
||||||
const encounter = encounters[encounterIndex]
|
const encounter = encounters[encounterIndex]
|
||||||
@@ -431,11 +487,22 @@ export function PvPRoguelikeScreen({
|
|||||||
const cpuDone = cpuSide.enemyHealth <= 0
|
const cpuDone = cpuSide.enemyHealth <= 0
|
||||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||||
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
|
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
|
||||||
|
const partyColumns = contentType === 'raid' ? 6 : 3
|
||||||
const {
|
const {
|
||||||
bindings,
|
bindings,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
|
directPartyTargeting,
|
||||||
lastDevice,
|
lastDevice,
|
||||||
} = useInput()
|
} = useInput()
|
||||||
|
const {
|
||||||
|
enabled: dualScreenEnabled,
|
||||||
|
} = useDualScreen()
|
||||||
|
|
||||||
|
const setSelectedTargetId = useCallback((id: string) => {
|
||||||
|
selectedIdRef.current = id
|
||||||
|
setSelectedId(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
||||||
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
|
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -449,31 +516,69 @@ export function PvPRoguelikeScreen({
|
|||||||
}, 900)
|
}, 900)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const finishRoguelikeRun = useCallback((cleared: number) => {
|
useEffect(() => {
|
||||||
if (rewardClaimedRef.current) return
|
if (queuedMatchRef.current) return
|
||||||
rewardClaimedRef.current = true
|
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||||
const bossesCleared = Math.floor(cleared / 3)
|
setCheckpointStage(loadedCheckpoint)
|
||||||
|
setStartStage(loadedCheckpoint)
|
||||||
|
}, [contentType, profile.character.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
encounterPoolRef.current = encounterPool
|
||||||
|
}, [encounterPool])
|
||||||
|
|
||||||
|
const awardBossReward = useCallback((encounterIndexValue: number) => {
|
||||||
|
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
||||||
|
bossRewardClaimedRef.current.add(encounterIndexValue)
|
||||||
|
const rewardEncounter = encounters[encounterIndexValue]
|
||||||
completeRoguelike(
|
completeRoguelike(
|
||||||
rewardDungeon.id,
|
rewardDungeon.id,
|
||||||
rewardDifficulty.id,
|
rewardDifficulty.id,
|
||||||
cleared,
|
0,
|
||||||
0,
|
0,
|
||||||
Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
|
Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
|
||||||
{
|
{
|
||||||
bossesCleared,
|
bossesCleared: 1,
|
||||||
experienceMode: 'pvp-boss-quarter-level',
|
experienceMode: 'pvp-boss-quarter-level',
|
||||||
|
lootSourceEncounterId: rewardEncounter?.sourceEncounterId,
|
||||||
|
roguelikeStage: stage,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setReward(result)
|
setReward(result)
|
||||||
|
setRunSummary((current) => {
|
||||||
|
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
|
||||||
|
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
|
||||||
|
return {
|
||||||
|
bossesKilled: current.bossesKilled + 1,
|
||||||
|
experienceGained: current.experienceGained + result.experienceGained,
|
||||||
|
previousLevel: current.previousLevel ?? result.previousLevel,
|
||||||
|
newLevel: result.newLevel,
|
||||||
|
levelsGained: current.levelsGained + result.levelsGained,
|
||||||
|
talentPointsGained: current.talentPointsGained + result.talentPointsGained,
|
||||||
|
unlockedAbilities: Array.from(unlockedById.values()),
|
||||||
|
loot: result.bonusItem ? [...current.loot, result.bonusItem] : current.loot,
|
||||||
|
}
|
||||||
|
})
|
||||||
onProfileUpdated(result.profile)
|
onProfileUpdated(result.profile)
|
||||||
|
if (result.bonusItem) {
|
||||||
|
addLog(
|
||||||
|
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
|
||||||
|
'loot',
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
setRewardError(
|
setRewardError(
|
||||||
reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
|
reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [elapsedTicks, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])
|
}, [addLog, elapsedTicks, encounters, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id, stage])
|
||||||
|
|
||||||
|
const finishRoguelikeRun = useCallback(() => {
|
||||||
|
if (rewardClaimedRef.current) return
|
||||||
|
rewardClaimedRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPlayerBuffChoices((current) => current
|
setPlayerBuffChoices((current) => current
|
||||||
@@ -490,8 +595,9 @@ export function PvPRoguelikeScreen({
|
|||||||
: null)
|
: null)
|
||||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||||
|
|
||||||
useEffect(() => {
|
const startMatch = useCallback((nextStartStage?: number) => {
|
||||||
const firstSegment = buildEncounterSegment(encounterPool, 1, contentType)
|
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||||
|
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
|
||||||
const firstEncounter = firstSegment[0]
|
const firstEncounter = firstSegment[0]
|
||||||
const basePlayer = starterSide(partyTemplate, maxResource)
|
const basePlayer = starterSide(partyTemplate, maxResource)
|
||||||
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
|
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
|
||||||
@@ -501,48 +607,58 @@ export function PvPRoguelikeScreen({
|
|||||||
cpuRef.current = baseCpu
|
cpuRef.current = baseCpu
|
||||||
nextLogId.current = 2
|
nextLogId.current = 2
|
||||||
playerClearedEncounterRef.current = -1
|
playerClearedEncounterRef.current = -1
|
||||||
|
queuedMatchRef.current = true
|
||||||
|
bossRewardClaimedRef.current = new Set()
|
||||||
setEncounters(firstSegment)
|
setEncounters(firstSegment)
|
||||||
setEncounterIndex(0)
|
setEncounterIndex(0)
|
||||||
setStage(1)
|
setCheckpointStage(matchStartStage)
|
||||||
|
setStartStage(matchStartStage)
|
||||||
|
setStage(matchStartStage)
|
||||||
setElapsedTicks(0)
|
setElapsedTicks(0)
|
||||||
setStatus('queueing')
|
setStatus('queueing')
|
||||||
setPlayerSide(basePlayer)
|
setPlayerSide(basePlayer)
|
||||||
setCpuSide(baseCpu)
|
setCpuSide(baseCpu)
|
||||||
setSelectedId(partyTemplate[0].id)
|
setSelectedTargetId(partyTemplate[0].id)
|
||||||
setPlayerBuffChoices([])
|
setPlayerBuffChoices([])
|
||||||
setPlayerDebuffChoices([])
|
setPlayerDebuffChoices([])
|
||||||
setSelectedBuff(null)
|
setSelectedBuff(null)
|
||||||
setSelectedDebuff(null)
|
setSelectedDebuff(null)
|
||||||
setEncountersCleared(0)
|
setEncountersCleared(0)
|
||||||
|
setPaused(false)
|
||||||
|
setTargetGroup(0)
|
||||||
setReward(null)
|
setReward(null)
|
||||||
|
setRunSummary(createEmptyPvpRunSummary())
|
||||||
setRewardError('')
|
setRewardError('')
|
||||||
setShowEndLog(false)
|
setShowEndLog(false)
|
||||||
setFloatingTexts([])
|
setFloatingTexts([])
|
||||||
setCpuDifficulty(null)
|
setCpuDifficulty(null)
|
||||||
recordedRunRef.current = false
|
recordedRunRef.current = false
|
||||||
rewardClaimedRef.current = false
|
rewardClaimedRef.current = false
|
||||||
|
cpuDefeatedRef.current = false
|
||||||
if (gameMode === 'offline') {
|
if (gameMode === 'offline') {
|
||||||
const randomCpu = randomCpuDifficulty()
|
const randomCpu = randomCpuDifficulty()
|
||||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters immediately.`)
|
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
|
||||||
setCpuDifficulty(randomCpu)
|
setCpuDifficulty(randomCpu)
|
||||||
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters immediately.`, tone: 'system' }])
|
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`Round 1 begins against CPU ${randomCpu}.`, 'system')
|
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
|
||||||
}, 500)
|
}, 500)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}
|
}
|
||||||
setQueueMessage('Searching queue. No player found yet.')
|
setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
|
||||||
setLog([{ id: 1, text: 'Searching queue. No player found yet.', tone: 'system' }])
|
setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
const randomCpu = randomCpuDifficulty()
|
const randomCpu = randomCpuDifficulty()
|
||||||
setCpuDifficulty(randomCpu)
|
setCpuDifficulty(randomCpu)
|
||||||
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`No queued player found. CPU ${randomCpu} steps in.`, 'system')
|
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
|
||||||
}, 1400)
|
}, 1400)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate])
|
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
|
||||||
|
|
||||||
|
useEffect(() => startMatch(), [startMatch])
|
||||||
|
|
||||||
const applySpell = useCallback((
|
const applySpell = useCallback((
|
||||||
current: SideState,
|
current: SideState,
|
||||||
@@ -565,6 +681,11 @@ export function PvPRoguelikeScreen({
|
|||||||
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
|
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
|
||||||
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
|
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
|
||||||
const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId)
|
const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId)
|
||||||
|
const groupTargets = new Set(
|
||||||
|
spell.kind === 'group'
|
||||||
|
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
for (let index = 0; index < extraTargets; index += 1) {
|
for (let index = 0; index < extraTargets; index += 1) {
|
||||||
if (spell.kind === 'group') break
|
if (spell.kind === 'group') break
|
||||||
if (spell.kind === 'hot') {
|
if (spell.kind === 'hot') {
|
||||||
@@ -583,6 +704,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const nextParty = current.party.map((member) => {
|
const nextParty = current.party.map((member) => {
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
if (spell.kind === 'group') {
|
if (spell.kind === 'group') {
|
||||||
|
if (!groupTargets.has(member.id)) return member
|
||||||
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
|
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
|
||||||
const nextHealth = healMember(member, groupPower, debuffs)
|
const nextHealth = healMember(member, groupPower, debuffs)
|
||||||
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
||||||
@@ -641,28 +763,64 @@ export function PvPRoguelikeScreen({
|
|||||||
|
|
||||||
const castPlayerSpell = useCallback((spell: Spell) => {
|
const castPlayerSpell = useCallback((spell: Spell) => {
|
||||||
if (status !== 'playing' || playerDone || !playerAlive) return
|
if (status !== 'playing' || playerDone || !playerAlive) return
|
||||||
|
const targetId = selectedIdRef.current
|
||||||
const succeeded = applySpell(playerRef.current, (value) => {
|
const succeeded = applySpell(playerRef.current, (value) => {
|
||||||
const next = typeof value === 'function' ? value(playerRef.current) : value
|
const next = typeof value === 'function' ? value(playerRef.current) : value
|
||||||
playerRef.current = next
|
playerRef.current = next
|
||||||
setPlayerSide(next)
|
setPlayerSide(next)
|
||||||
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, selectedId)
|
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId)
|
||||||
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === selectedId)?.name ?? 'target'}.`, 'heal')
|
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
|
||||||
}, [addLog, applySpell, playerAlive, playerDone, selectedId, status])
|
}, [addLog, applySpell, playerAlive, playerDone, status])
|
||||||
|
|
||||||
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
||||||
const living = playerRef.current.party.filter((member) => member.health > 0)
|
const living = playerRef.current.party.filter((member) => member.health > 0)
|
||||||
if (living.length === 0) return
|
if (living.length === 0) return
|
||||||
const currentIndex = living.findIndex((member) => member.id === selectedId)
|
const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
|
||||||
const nextIndex = currentIndex < 0
|
const nextIndex = currentIndex < 0
|
||||||
? 0
|
? 0
|
||||||
: (currentIndex + direction + living.length) % living.length
|
: (currentIndex + direction + living.length) % living.length
|
||||||
setSelectedId(living[nextIndex].id)
|
setSelectedTargetId(living[nextIndex].id)
|
||||||
}, [selectedId])
|
}, [setSelectedTargetId])
|
||||||
|
|
||||||
|
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
||||||
|
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
||||||
|
if (currentIndex < 0) {
|
||||||
|
const firstLiving = playerRef.current.party.find((member) => member.health > 0)
|
||||||
|
if (firstLiving) setSelectedTargetId(firstLiving.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentRow = Math.floor(currentIndex / partyColumns)
|
||||||
|
const currentColumn = currentIndex % partyColumns
|
||||||
|
const candidates = playerRef.current.party
|
||||||
|
.map((member, index) => ({
|
||||||
|
member,
|
||||||
|
index,
|
||||||
|
row: Math.floor(index / partyColumns),
|
||||||
|
column: index % partyColumns,
|
||||||
|
}))
|
||||||
|
.filter(({ member, index, row, column }) => {
|
||||||
|
if (member.health <= 0 || index === currentIndex) return false
|
||||||
|
if (action === 'navigateLeft') return row === currentRow && column < currentColumn
|
||||||
|
if (action === 'navigateRight') return row === currentRow && column > currentColumn
|
||||||
|
if (action === 'navigateUp') return row < currentRow
|
||||||
|
return row > currentRow
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const horizontal = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
const aPrimary = horizontal ? Math.abs(a.column - currentColumn) : Math.abs(a.row - currentRow)
|
||||||
|
const bPrimary = horizontal ? Math.abs(b.column - currentColumn) : Math.abs(b.row - currentRow)
|
||||||
|
const aSecondary = horizontal ? 0 : Math.abs(a.column - currentColumn)
|
||||||
|
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
|
||||||
|
return aPrimary - bPrimary || aSecondary - bSecondary
|
||||||
|
})
|
||||||
|
if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
|
||||||
|
}, [partyColumns, setSelectedTargetId])
|
||||||
|
|
||||||
const selectDirectTarget = useCallback((slot: number) => {
|
const selectDirectTarget = useCallback((slot: number) => {
|
||||||
const member = playerRef.current.party[slot]
|
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
|
||||||
if (member?.health > 0) setSelectedId(member.id)
|
const member = playerRef.current.party[index]
|
||||||
}, [])
|
if (member?.health > 0) setSelectedTargetId(member.id)
|
||||||
|
}, [contentType, setSelectedTargetId, targetGroup])
|
||||||
|
|
||||||
const cpuTakeTurn = useCallback(() => {
|
const cpuTakeTurn = useCallback(() => {
|
||||||
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
|
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
|
||||||
@@ -709,10 +867,14 @@ export function PvPRoguelikeScreen({
|
|||||||
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
|
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
|
||||||
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
|
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
|
||||||
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
||||||
|
const tankPressure = tankPressureTargets(side.party)
|
||||||
|
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||||
const nextParty = side.party.map((member) => {
|
const nextParty = side.party.map((member) => {
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
|
let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
|
||||||
if (member.role === 'Tank') damage += encounterValue.tankDamage
|
if (tankPressureIds.has(member.id)) {
|
||||||
|
damage += Math.round(encounterValue.tankDamage * tankPressure.multiplier)
|
||||||
|
}
|
||||||
if (bossPulse) damage += 10
|
if (bossPulse) damage += 10
|
||||||
if (member.debuff) damage += 6
|
if (member.debuff) damage += 6
|
||||||
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
||||||
@@ -761,7 +923,7 @@ export function PvPRoguelikeScreen({
|
|||||||
cooldowns: Object.fromEntries(
|
cooldowns: Object.fromEntries(
|
||||||
Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
|
Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
|
||||||
),
|
),
|
||||||
enemyHealth: Math.max(0, side.enemyHealth - encounterValue.partyDamage),
|
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
||||||
}
|
}
|
||||||
}, [addFloatingHeal, elapsedTicks, maxResource])
|
}, [addFloatingHeal, elapsedTicks, maxResource])
|
||||||
|
|
||||||
@@ -774,7 +936,7 @@ export function PvPRoguelikeScreen({
|
|||||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== 'playing' || !encounter) return
|
if (status !== 'playing' || paused || !encounter) return
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
setElapsedTicks((value) => value + 1)
|
setElapsedTicks((value) => value + 1)
|
||||||
cpuTakeTurn()
|
cpuTakeTurn()
|
||||||
@@ -783,6 +945,18 @@ export function PvPRoguelikeScreen({
|
|||||||
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
|
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
|
||||||
playerClearedEncounterRef.current = encounterIndex
|
playerClearedEncounterRef.current = encounterIndex
|
||||||
setEncountersCleared((value) => value + 1)
|
setEncountersCleared((value) => value + 1)
|
||||||
|
if (encounter.isBoss) {
|
||||||
|
awardBossReward(encounterIndex)
|
||||||
|
const nextCheckpoint = recordPvpRoguelikeCheckpoint(
|
||||||
|
profile.character.id,
|
||||||
|
contentType,
|
||||||
|
stage,
|
||||||
|
)
|
||||||
|
if (nextCheckpoint > checkpointStage) {
|
||||||
|
setCheckpointStage(nextCheckpoint)
|
||||||
|
addLog(`Stage ${nextCheckpoint} checkpoint unlocked.`, 'loot')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
playerRef.current = nextPlayer
|
playerRef.current = nextPlayer
|
||||||
cpuRef.current = nextCpu
|
cpuRef.current = nextCpu
|
||||||
@@ -791,28 +965,29 @@ export function PvPRoguelikeScreen({
|
|||||||
|
|
||||||
const nextPlayerAlive = nextPlayer.party.some((member) => member.health > 0)
|
const nextPlayerAlive = nextPlayer.party.some((member) => member.health > 0)
|
||||||
const nextCpuAlive = nextCpu.party.some((member) => member.health > 0)
|
const nextCpuAlive = nextCpu.party.some((member) => member.health > 0)
|
||||||
const clearedCount = nextPlayer.enemyHealth <= 0
|
|
||||||
? Math.max(encountersCleared, encounterIndex + 1)
|
|
||||||
: encountersCleared
|
|
||||||
if (!nextPlayerAlive) {
|
if (!nextPlayerAlive) {
|
||||||
finishRoguelikeRun(clearedCount)
|
finishRoguelikeRun()
|
||||||
setStatus('lost')
|
setStatus('lost')
|
||||||
addLog('Your party fell first.', 'danger')
|
addLog('Your party fell first.', 'danger')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!nextCpuAlive) {
|
if (!nextCpuAlive && !cpuDefeatedRef.current) {
|
||||||
finishRoguelikeRun(clearedCount)
|
cpuDefeatedRef.current = true
|
||||||
|
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
||||||
|
}
|
||||||
|
if (nextPlayer.enemyHealth <= 0) {
|
||||||
|
if (encounter.isBoss && cpuDefeatedRef.current) {
|
||||||
|
finishRoguelikeRun()
|
||||||
setStatus('won')
|
setStatus('won')
|
||||||
addLog(`CPU ${cpuDifficulty ?? 1} fell first.`, 'loot')
|
addLog('CPU defeated. Match complete.', 'loot')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (nextPlayer.enemyHealth <= 0 && nextCpu.enemyHealth <= 0) {
|
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||||
addLog(`${encounter.enemyName} cleared by both teams. Choose your next edge.`, 'loot')
|
|
||||||
beginUpgradePhase()
|
beginUpgradePhase()
|
||||||
}
|
}
|
||||||
}, TICK_MS)
|
}, TICK_MS)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [addLog, advanceSide, beginUpgradePhase, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, status])
|
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, stage, status])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||||
@@ -828,6 +1003,16 @@ export function PvPRoguelikeScreen({
|
|||||||
})
|
})
|
||||||
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
|
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'upgrade-choice') return
|
||||||
|
window.requestAnimationFrame(() => focusFirstControl())
|
||||||
|
}, [status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!paused) return
|
||||||
|
window.requestAnimationFrame(() => focusFirstControl())
|
||||||
|
}, [paused])
|
||||||
|
|
||||||
const confirmUpgradeChoices = useCallback(() => {
|
const confirmUpgradeChoices = useCallback(() => {
|
||||||
if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return
|
if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return
|
||||||
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
|
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
|
||||||
@@ -857,10 +1042,17 @@ export function PvPRoguelikeScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clearedBoss = encounter.isBoss
|
const clearedBoss = encounter.isBoss
|
||||||
|
if (clearedBoss && cpuDefeatedRef.current) {
|
||||||
|
finishRoguelikeRun()
|
||||||
|
setStatus('won')
|
||||||
|
addLog('CPU defeated. Match complete.', 'loot')
|
||||||
|
return
|
||||||
|
}
|
||||||
const nextStage = clearedBoss ? stage + 1 : stage
|
const nextStage = clearedBoss ? stage + 1 : stage
|
||||||
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
|
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
|
||||||
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
||||||
if (!nextEncounter) {
|
if (!nextEncounter) {
|
||||||
|
finishRoguelikeRun()
|
||||||
setStatus('won')
|
setStatus('won')
|
||||||
addLog('No further encounters remain.', 'loot')
|
addLog('No further encounters remain.', 'loot')
|
||||||
return
|
return
|
||||||
@@ -909,10 +1101,18 @@ export function PvPRoguelikeScreen({
|
|||||||
setElapsedTicks(0)
|
setElapsedTicks(0)
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
||||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||||
|
|
||||||
useGameAction((action) => {
|
useGameAction((action) => {
|
||||||
if (status !== 'playing') return
|
if (action === 'pause' || action === 'back') {
|
||||||
|
if (status === 'playing') setPaused((value) => !value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (paused || status !== 'playing') return
|
||||||
|
if (action.startsWith('navigate')) {
|
||||||
|
selectDirectionalTarget(action)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action === 'previousTarget') {
|
if (action === 'previousTarget') {
|
||||||
selectRelativeTarget(-1)
|
selectRelativeTarget(-1)
|
||||||
return
|
return
|
||||||
@@ -925,41 +1125,93 @@ export function PvPRoguelikeScreen({
|
|||||||
selectDirectTarget(Number(action.slice('targetParty'.length)) - 1)
|
selectDirectTarget(Number(action.slice('targetParty'.length)) - 1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (action === 'toggleTargetGroup') {
|
||||||
|
if (contentType !== 'raid') return
|
||||||
|
setTargetGroup((current) => {
|
||||||
|
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
|
||||||
|
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
||||||
|
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
||||||
|
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
||||||
|
if (nextMember?.health > 0) setSelectedTargetId(nextMember.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action.startsWith('ability')) {
|
if (action.startsWith('ability')) {
|
||||||
const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length))
|
const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length))
|
||||||
if (spell) castPlayerSpell(spell)
|
if (spell) castPlayerSpell(spell)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
|
||||||
<main className="game-shell" data-combat-active={status === 'playing' ? 'true' : 'false'}>
|
difficultyName: `Stage ${stage}`,
|
||||||
<section className="content-screen pvp-match-screen">
|
dungeonName: encounter.enemyName,
|
||||||
<div className="screen-heading">
|
contentName: 'PvP Roguelike',
|
||||||
<div>
|
encounterName: encounter.enemyName,
|
||||||
<p className="eyebrow">PvP Roguelike</p>
|
encounterDescription: encounter.description,
|
||||||
<h1>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h1>
|
encounterHealth: playerSide.enemyHealth,
|
||||||
</div>
|
encounterMaxHealth: encounter.maxHealth,
|
||||||
<div className="pvp-screen-tools">
|
encounterIsBoss: encounter.isBoss,
|
||||||
<div className="roguelike-timing-row">
|
encounterIndex,
|
||||||
<button
|
encounterCount: encounters.length,
|
||||||
className={`text-button ${abilityLabelMode === 'ability' ? 'active' : ''}`}
|
party: playerSide.party,
|
||||||
onClick={() => setAbilityLabelMode('ability')}
|
partySize: playerSide.party.length,
|
||||||
type="button"
|
selectedId,
|
||||||
>
|
log,
|
||||||
Ability Names
|
status: status === 'queueing' ? 'playing' : status,
|
||||||
</button>
|
resource: playerSide.resource,
|
||||||
<button
|
maxResource,
|
||||||
className={`text-button ${abilityLabelMode === 'slot' ? 'active' : ''}`}
|
resourceName: gameClass.resourceName,
|
||||||
onClick={() => setAbilityLabelMode('slot')}
|
playerIsAlive: playerAlive,
|
||||||
type="button"
|
spells: starterSpells.map((spell, slotIndex) => ({
|
||||||
>
|
...spell,
|
||||||
Slot Names
|
cost: spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady),
|
||||||
</button>
|
slotIndex,
|
||||||
</div>
|
remaining: playerSide.cooldowns[spell.id] ?? 0,
|
||||||
<button className="back-button" onClick={onExit} type="button">Leave</button>
|
})),
|
||||||
</div>
|
activeDevice: lastDevice,
|
||||||
</div>
|
bindings: bindings[lastDevice],
|
||||||
|
controllerIconStyle,
|
||||||
|
directPartyTargeting,
|
||||||
|
paused,
|
||||||
|
targetGroup,
|
||||||
|
}), [
|
||||||
|
bindings,
|
||||||
|
controllerIconStyle,
|
||||||
|
directPartyTargeting,
|
||||||
|
encounter.description,
|
||||||
|
encounter.enemyName,
|
||||||
|
encounter.isBoss,
|
||||||
|
encounter.maxHealth,
|
||||||
|
encounterIndex,
|
||||||
|
encounters.length,
|
||||||
|
gameClass.resourceName,
|
||||||
|
lastDevice,
|
||||||
|
log,
|
||||||
|
maxResource,
|
||||||
|
paused,
|
||||||
|
playerAlive,
|
||||||
|
playerSide.buffs,
|
||||||
|
playerSide.cooldowns,
|
||||||
|
playerSide.debuffs,
|
||||||
|
playerSide.enemyHealth,
|
||||||
|
playerSide.freeCastReady,
|
||||||
|
playerSide.party,
|
||||||
|
playerSide.resource,
|
||||||
|
selectedId,
|
||||||
|
stage,
|
||||||
|
starterSpells,
|
||||||
|
status,
|
||||||
|
targetGroup,
|
||||||
|
])
|
||||||
|
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
|
||||||
|
data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
<section className="content-screen pvp-match-screen">
|
||||||
{status === 'queueing' && (
|
{status === 'queueing' && (
|
||||||
<div className="placeholder-panel">
|
<div className="placeholder-panel">
|
||||||
<div className="placeholder-runes">P V P</div>
|
<div className="placeholder-runes">P V P</div>
|
||||||
@@ -967,7 +1219,14 @@ export function PvPRoguelikeScreen({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status !== 'queueing' && (
|
{dualScreenEnabled && status !== 'queueing' && (
|
||||||
|
<DualScreenTopCombat
|
||||||
|
state={dualScreenState}
|
||||||
|
onSelectTarget={setSelectedTargetId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!dualScreenEnabled && status !== 'queueing' && (
|
||||||
<div className="pvp-board">
|
<div className="pvp-board">
|
||||||
<section className="combat-panel pvp-side">
|
<section className="combat-panel pvp-side">
|
||||||
<div className="encounter-header">
|
<div className="encounter-header">
|
||||||
@@ -982,12 +1241,12 @@ export function PvPRoguelikeScreen({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="party-grid">
|
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
|
||||||
{playerSide.party.map((member) => (
|
{playerSide.party.map((member) => (
|
||||||
<button
|
<button
|
||||||
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
|
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
|
||||||
key={`player-${member.id}`}
|
key={`player-${member.id}`}
|
||||||
onClick={() => setSelectedId(member.id)}
|
onClick={() => setSelectedTargetId(member.id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="member-header">
|
<div className="member-header">
|
||||||
@@ -998,6 +1257,7 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="bar member-health">
|
<div className="bar member-health">
|
||||||
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
||||||
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
||||||
|
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
|
||||||
</div>
|
</div>
|
||||||
<div className="floating-combat-texts" aria-hidden="true">
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
{floatingTexts
|
{floatingTexts
|
||||||
@@ -1087,7 +1347,7 @@ export function PvPRoguelikeScreen({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="party-grid">
|
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
|
||||||
{cpuSide.party.map((member) => (
|
{cpuSide.party.map((member) => (
|
||||||
<div className={`party-member ${member.health <= 0 ? 'down' : ''}`} key={`cpu-${member.id}`}>
|
<div className={`party-member ${member.health <= 0 ? 'down' : ''}`} key={`cpu-${member.id}`}>
|
||||||
<div className="member-header">
|
<div className="member-header">
|
||||||
@@ -1098,6 +1358,7 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="bar member-health">
|
<div className="bar member-health">
|
||||||
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
||||||
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
||||||
|
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
|
||||||
</div>
|
</div>
|
||||||
<div className="floating-combat-texts" aria-hidden="true">
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
{floatingTexts
|
{floatingTexts
|
||||||
@@ -1125,9 +1386,6 @@ export function PvPRoguelikeScreen({
|
|||||||
{status === 'upgrade-choice' && (
|
{status === 'upgrade-choice' && (
|
||||||
<div className="result-screen">
|
<div className="result-screen">
|
||||||
<div className="pvp-upgrade-dialog">
|
<div className="pvp-upgrade-dialog">
|
||||||
<p className="eyebrow">Round Cleared</p>
|
|
||||||
<h2>Choose Your Edge</h2>
|
|
||||||
<p>Take 1 buff for yourself and 1 debuff for the CPU.</p>
|
|
||||||
<div className="pvp-choice-columns">
|
<div className="pvp-choice-columns">
|
||||||
<div>
|
<div>
|
||||||
<strong>Self Buff</strong>
|
<strong>Self Buff</strong>
|
||||||
@@ -1169,6 +1427,17 @@ export function PvPRoguelikeScreen({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{paused && (
|
||||||
|
<div className="pause-screen">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Paused</p>
|
||||||
|
<h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
|
||||||
|
<button onClick={() => setPaused(false)} type="button">Resume</button>
|
||||||
|
<button className="secondary-result-button" onClick={onExit} type="button">Leave</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(status === 'won' || status === 'lost') && (
|
{(status === 'won' || status === 'lost') && (
|
||||||
<div className="result-screen">
|
<div className="result-screen">
|
||||||
<div>
|
<div>
|
||||||
@@ -1176,9 +1445,39 @@ export function PvPRoguelikeScreen({
|
|||||||
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
|
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
|
||||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||||
<div className="reward-summary">
|
<div className="reward-summary">
|
||||||
{!reward && !rewardError && <p>Recording roguelike progress...</p>}
|
<p>{runSummary.bossesKilled} bosses killed.</p>
|
||||||
|
<p>+{runSummary.experienceGained} XP</p>
|
||||||
|
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
|
||||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||||
{reward && (
|
{runSummary.levelsGained > 0 && runSummary.previousLevel !== null && runSummary.newLevel !== null && (
|
||||||
|
<p className="level-gain">
|
||||||
|
Level {runSummary.previousLevel} to {runSummary.newLevel}
|
||||||
|
<small>+{runSummary.talentPointsGained} talent point{runSummary.talentPointsGained === 1 ? '' : 's'}</small>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{runSummary.unlockedAbilities.map((ability) => (
|
||||||
|
<p className="ability-unlock" key={ability.id}>
|
||||||
|
<span>{ability.glyph}</span>
|
||||||
|
Ability Unlocked: {ability.name}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<div className="run-loot-rolls">
|
||||||
|
{runSummary.loot.length > 0 ? runSummary.loot.map((item, index) => (
|
||||||
|
<div className="dropped" key={`${item.id}-${index}`}>
|
||||||
|
<strong>Boss {index + 1}</strong>
|
||||||
|
<span>
|
||||||
|
{item.glyph} {item.name} x{item.quantity}
|
||||||
|
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div>
|
||||||
|
<strong>Loot</strong>
|
||||||
|
<span>No boss loot awarded</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{reward && runSummary.bossesKilled === 0 && (
|
||||||
<>
|
<>
|
||||||
<p>+{reward.experienceGained} XP</p>
|
<p>+{reward.experienceGained} XP</p>
|
||||||
{reward.levelsGained > 0 && (
|
{reward.levelsGained > 0 && (
|
||||||
@@ -1193,6 +1492,13 @@ export function PvPRoguelikeScreen({
|
|||||||
Ability Unlocked: {ability.name}
|
Ability Unlocked: {ability.name}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
{reward.bonusItem && (
|
||||||
|
<p className="ability-unlock">
|
||||||
|
<span>{reward.bonusItem.glyph}</span>
|
||||||
|
{reward.bonusItem.name} x{reward.bonusItem.quantity}
|
||||||
|
{reward.bonusItem.duplicate ? ` (owned x${reward.bonusItem.quantityAfter})` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1210,6 +1516,7 @@ export function PvPRoguelikeScreen({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button onClick={() => startMatch()} type="button">Queue Next Match</button>
|
||||||
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
|
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
|
|
||||||
export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||||
const [device, setDevice] = useState<InputDevice>('controller')
|
const [device, setDevice] = useState<InputDevice>('controller')
|
||||||
|
const [settingsTab, setSettingsTab] = useState<'display' | 'input' | 'bindings'>('display')
|
||||||
const [displayMessage, setDisplayMessage] = useState('')
|
const [displayMessage, setDisplayMessage] = useState('')
|
||||||
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
|
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
|
||||||
const {
|
const {
|
||||||
@@ -50,6 +51,7 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
'targetParty3',
|
'targetParty3',
|
||||||
'targetParty4',
|
'targetParty4',
|
||||||
'targetParty5',
|
'targetParty5',
|
||||||
|
'targetParty6',
|
||||||
'toggleTargetGroup',
|
'toggleTargetGroup',
|
||||||
])
|
])
|
||||||
const visibleActions = INPUT_ACTIONS.filter((action) => (
|
const visibleActions = INPUT_ACTIONS.filter((action) => (
|
||||||
@@ -95,7 +97,27 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="dual-screen-settings">
|
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
|
||||||
|
{([
|
||||||
|
{ key: 'display', label: 'Display' },
|
||||||
|
{ key: 'input', label: 'Input' },
|
||||||
|
{ key: 'bindings', label: 'Bindings' },
|
||||||
|
] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
aria-selected={settingsTab === tab.key}
|
||||||
|
className={settingsTab === tab.key ? 'selected' : ''}
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setSettingsTab(tab.key)}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{settingsTab === 'display' && (
|
||||||
|
<section className="dual-screen-settings settings-tab-panel">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Display</p>
|
<p className="eyebrow">Display</p>
|
||||||
<h2>AYN Thor Dual-Screen Mode</h2>
|
<h2>AYN Thor Dual-Screen Mode</h2>
|
||||||
@@ -131,29 +153,23 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
{androidDisplays.map((display) => (
|
{androidDisplays.map((display) => (
|
||||||
<span key={display.id}>
|
<span key={display.id}>
|
||||||
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
|
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
|
||||||
{display.width}×{display.height} at {Math.round(display.refreshRate)} Hz
|
{display.width}x{display.height} at {Math.round(display.refreshRate)} Hz
|
||||||
{display.isPresentation ? ' - Presentation' : ''}
|
{display.isPresentation ? ' - Presentation' : ''}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="settings-heading">
|
{settingsTab === 'input' && (
|
||||||
<div>
|
<section className="controller-preferences settings-tab-panel">
|
||||||
<p className="eyebrow">Input</p>
|
|
||||||
<h2>Keybindings</h2>
|
|
||||||
</div>
|
|
||||||
<p>Select an action, then press the new key or controller control.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="controller-preferences">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Targeting</p>
|
<p className="eyebrow">Targeting</p>
|
||||||
<h3>Direct Party Keybinds</h3>
|
<h3>Direct Party Keybinds</h3>
|
||||||
<p>
|
<p>
|
||||||
Assign party slots directly. In raids, use the group-switch binding
|
Assign party slots directly. In raids, use the group-switch binding
|
||||||
to alternate between members 1-5 and 6-10.
|
to alternate between members 1-6, 7-12, and 13-18.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -180,6 +196,17 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settingsTab === 'bindings' && (
|
||||||
|
<section className="settings-bindings-panel settings-tab-panel">
|
||||||
|
<div className="settings-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Input</p>
|
||||||
|
<h2>Keybindings</h2>
|
||||||
|
</div>
|
||||||
|
<p>Select an action, then press the new key or controller control.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="binding-tabs">
|
<div className="binding-tabs">
|
||||||
<button
|
<button
|
||||||
@@ -227,6 +254,8 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
|
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{capture && (
|
{capture && (
|
||||||
<div className="binding-capture" role="dialog" aria-modal="true">
|
<div className="binding-capture" role="dialog" aria-modal="true">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Props = {
|
|||||||
|
|
||||||
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
||||||
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
||||||
|
const [talentPage, setTalentPage] = useState(0)
|
||||||
const [resetting, setResetting] = useState(false)
|
const [resetting, setResetting] = useState(false)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const scrollRef = useRef<number>(0)
|
const scrollRef = useRef<number>(0)
|
||||||
@@ -28,6 +29,11 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
|||||||
const tiers = Array.from(
|
const tiers = Array.from(
|
||||||
new Set(gameClass.talents.map((talent) => talent.tier)),
|
new Set(gameClass.talents.map((talent) => talent.tier)),
|
||||||
).sort((a, b) => a - b)
|
).sort((a, b) => a - b)
|
||||||
|
const tierPages = Array.from(
|
||||||
|
{ length: Math.ceil(tiers.length / 2) },
|
||||||
|
(_, index) => tiers.slice(index * 2, index * 2 + 2),
|
||||||
|
)
|
||||||
|
const visibleTiers = tierPages[talentPage] ?? tierPages[0] ?? []
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.scrollTo(0, scrollRef.current)
|
window.scrollTo(0, scrollRef.current)
|
||||||
@@ -123,8 +129,23 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages">
|
||||||
|
{tierPages.map((pageTiers, index) => (
|
||||||
|
<button
|
||||||
|
aria-selected={talentPage === index}
|
||||||
|
className={talentPage === index ? 'active' : ''}
|
||||||
|
key={pageTiers.join('-')}
|
||||||
|
onClick={() => setTalentPage(index)}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Tiers {pageTiers[0]}-{pageTiers[pageTiers.length - 1]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div className="talent-tree">
|
<div className="talent-tree">
|
||||||
{tiers.map((tier) => {
|
{visibleTiers.map((tier) => {
|
||||||
const requiredPoints = (tier - 1) * 5
|
const requiredPoints = (tier - 1) * 5
|
||||||
return (
|
return (
|
||||||
<section className="talent-tier" key={tier}>
|
<section className="talent-tier" key={tier}>
|
||||||
|
|||||||
+180
-14
@@ -11,6 +11,7 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import type { CombatLogEntry, PartyMember, Spell } from './game'
|
import type { CombatLogEntry, PartyMember, Spell } from './game'
|
||||||
import {
|
import {
|
||||||
|
getNativeDisplays,
|
||||||
hasNativeDualScreenBridge,
|
hasNativeDualScreenBridge,
|
||||||
openNativeTopDisplay,
|
openNativeTopDisplay,
|
||||||
} from './nativeDualScreen'
|
} from './nativeDualScreen'
|
||||||
@@ -23,6 +24,7 @@ import { ControllerBindingLabel } from './components/ControllerIcons'
|
|||||||
|
|
||||||
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
|
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
|
||||||
const SNAPSHOT_KEY = 'ashen-halls-dual-screen-snapshot'
|
const SNAPSHOT_KEY = 'ashen-halls-dual-screen-snapshot'
|
||||||
|
const STARTUP_CHOICE_KEY = 'ashen-halls-dual-screen-startup-choice'
|
||||||
const CHANNEL_NAME = 'ashen-halls-dual-screen'
|
const CHANNEL_NAME = 'ashen-halls-dual-screen'
|
||||||
|
|
||||||
export type DualScreenCombatState = {
|
export type DualScreenCombatState = {
|
||||||
@@ -51,15 +53,31 @@ export type DualScreenCombatState = {
|
|||||||
controllerIconStyle: ControllerIconStyle
|
controllerIconStyle: ControllerIconStyle
|
||||||
directPartyTargeting: boolean
|
directPartyTargeting: boolean
|
||||||
paused: boolean
|
paused: boolean
|
||||||
targetGroup: 0 | 1
|
targetGroup: 0 | 1 | 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DualScreenWorkshopState = {
|
||||||
|
mode: 'class' | 'equipment' | 'crafting' | 'talents'
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
summary?: string
|
||||||
|
items: Array<{
|
||||||
|
glyph?: string
|
||||||
|
title: string
|
||||||
|
meta?: string
|
||||||
|
detail?: string
|
||||||
|
status?: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
type DualScreenMessage =
|
type DualScreenMessage =
|
||||||
| { type: 'combat-state'; state: DualScreenCombatState }
|
| { type: 'combat-state'; state: DualScreenCombatState }
|
||||||
|
| { type: 'workshop-state'; state: DualScreenWorkshopState }
|
||||||
| { type: 'companion-ready' }
|
| { type: 'companion-ready' }
|
||||||
| { type: 'companion-heartbeat' }
|
| { type: 'companion-heartbeat' }
|
||||||
| { type: 'control-action'; action: InputAction }
|
| { type: 'control-action'; action: InputAction }
|
||||||
| { type: 'combat-ended' }
|
| { type: 'combat-ended' }
|
||||||
|
| { type: 'workshop-ended' }
|
||||||
|
|
||||||
type DualScreenContextValue = {
|
type DualScreenContextValue = {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -172,6 +190,73 @@ export function useDualScreen() {
|
|||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DualScreenStartupPrompt() {
|
||||||
|
const { openTopDisplay, setEnabled } = useDualScreen()
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [displayCount, setDisplayCount] = useState<number | null>(null)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const autoOpenedRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasNativeDualScreenBridge()) return
|
||||||
|
if (new URLSearchParams(window.location.search).has('display')) return
|
||||||
|
const choice = localStorage.getItem(STARTUP_CHOICE_KEY)
|
||||||
|
if (choice === 'yes') {
|
||||||
|
if (autoOpenedRef.current) return
|
||||||
|
autoOpenedRef.current = true
|
||||||
|
openTopDisplay().catch(() => {
|
||||||
|
// Settings can still launch the display manually if Android rejects startup launch.
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (choice === 'no') return
|
||||||
|
getNativeDisplays()
|
||||||
|
.then((result) => setDisplayCount(result.displays.length))
|
||||||
|
.catch(() => setDisplayCount(null))
|
||||||
|
.finally(() => setVisible(true))
|
||||||
|
}, [openTopDisplay])
|
||||||
|
|
||||||
|
async function enableDualScreen() {
|
||||||
|
localStorage.setItem(STARTUP_CHOICE_KEY, 'yes')
|
||||||
|
setMessage('Opening second display...')
|
||||||
|
const opened = await openTopDisplay()
|
||||||
|
if (opened) {
|
||||||
|
setVisible(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMessage('No second display found. Check Thor display mode, then try again.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipDualScreen() {
|
||||||
|
localStorage.setItem(STARTUP_CHOICE_KEY, 'no')
|
||||||
|
setEnabled(false)
|
||||||
|
setVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dual-startup-prompt" role="dialog" aria-modal="true">
|
||||||
|
<section>
|
||||||
|
<p className="eyebrow">Display Setup</p>
|
||||||
|
<h2>Use Dual-Screen Mode?</h2>
|
||||||
|
<p>
|
||||||
|
Choose yes on AYN Thor. The game opens the combat view on the upper
|
||||||
|
display and keeps controls on the lower display.
|
||||||
|
</p>
|
||||||
|
{displayCount !== null && (
|
||||||
|
<small>{displayCount} Android display{displayCount === 1 ? '' : 's'} detected.</small>
|
||||||
|
)}
|
||||||
|
{message && <small>{message}</small>}
|
||||||
|
<div>
|
||||||
|
<button onClick={enableDualScreen} type="button">Yes, Enable</button>
|
||||||
|
<button onClick={skipDualScreen} type="button">No</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function useDualScreenPublisher(
|
export function useDualScreenPublisher(
|
||||||
state: DualScreenCombatState,
|
state: DualScreenCombatState,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
@@ -211,16 +296,64 @@ export function useDualScreenPublisher(
|
|||||||
}, [enabled, state])
|
}, [enabled, state])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDualScreenWorkshopPublisher(
|
||||||
|
state: DualScreenWorkshopState | null,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
const stateRef = useRef(state)
|
||||||
|
useEffect(() => {
|
||||||
|
stateRef.current = state
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !state) return
|
||||||
|
const channel = createChannel()
|
||||||
|
if (!channel) return
|
||||||
|
const publish = () => {
|
||||||
|
if (stateRef.current) {
|
||||||
|
channel.postMessage({
|
||||||
|
type: 'workshop-state',
|
||||||
|
state: stateRef.current,
|
||||||
|
} satisfies DualScreenMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||||
|
if (event.data.type === 'companion-ready') publish()
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
return () => {
|
||||||
|
channel.postMessage({ type: 'workshop-ended' } satisfies DualScreenMessage)
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
}, [enabled, state])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !state) return
|
||||||
|
const channel = createChannel()
|
||||||
|
channel?.postMessage({ type: 'workshop-state', state } satisfies DualScreenMessage)
|
||||||
|
channel?.close()
|
||||||
|
}, [enabled, state])
|
||||||
|
}
|
||||||
|
|
||||||
export function DualScreenBottomDisplay() {
|
export function DualScreenBottomDisplay() {
|
||||||
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
||||||
|
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const channel = createChannel()
|
const channel = createChannel()
|
||||||
if (!channel) return
|
if (!channel) return
|
||||||
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
|
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
|
||||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||||
if (event.data.type === 'combat-state') setState(event.data.state)
|
if (event.data.type === 'combat-state') {
|
||||||
|
setState(event.data.state)
|
||||||
|
setWorkshopState(null)
|
||||||
|
}
|
||||||
|
if (event.data.type === 'workshop-state') {
|
||||||
|
setWorkshopState(event.data.state)
|
||||||
|
setState(null)
|
||||||
|
}
|
||||||
if (event.data.type === 'combat-ended') setState(null)
|
if (event.data.type === 'combat-ended') setState(null)
|
||||||
|
if (event.data.type === 'workshop-ended') setWorkshopState(null)
|
||||||
}
|
}
|
||||||
announce()
|
announce()
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
@@ -238,6 +371,40 @@ export function DualScreenBottomDisplay() {
|
|||||||
channel?.close()
|
channel?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!state && workshopState) {
|
||||||
|
return (
|
||||||
|
<main className="dual-bottom-display workshop-bottom-display">
|
||||||
|
<header className="dual-controls-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{workshopState.mode}</p>
|
||||||
|
<h1>{workshopState.title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="dual-controls-progress">
|
||||||
|
<span>{workshopState.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{workshopState.summary && (
|
||||||
|
<section className="workshop-bottom-summary">
|
||||||
|
{workshopState.summary}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<section className="workshop-bottom-grid">
|
||||||
|
{workshopState.items.map((item, index) => (
|
||||||
|
<article key={`${item.title}-${index}`}>
|
||||||
|
{item.glyph && <span>{item.glyph}</span>}
|
||||||
|
<div>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
{item.meta && <small>{item.meta}</small>}
|
||||||
|
{item.detail && <p>{item.detail}</p>}
|
||||||
|
</div>
|
||||||
|
{item.status && <i>{item.status}</i>}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return (
|
return (
|
||||||
<main className="dual-bottom-display dual-bottom-waiting">
|
<main className="dual-bottom-display dual-bottom-waiting">
|
||||||
@@ -280,9 +447,9 @@ export function DualScreenBottomDisplay() {
|
|||||||
<div className={`dual-controls-targets ${state.directPartyTargeting ? 'direct' : ''}`}>
|
<div className={`dual-controls-targets ${state.directPartyTargeting ? 'direct' : ''}`}>
|
||||||
{state.directPartyTargeting ? (
|
{state.directPartyTargeting ? (
|
||||||
<>
|
<>
|
||||||
{([1, 2, 3, 4, 5] as const).map((slot) => {
|
{([1, 2, 3, 4, 5, 6] as const).map((slot) => {
|
||||||
const action = `targetParty${slot}` as InputAction
|
const action = `targetParty${slot}` as InputAction
|
||||||
const memberIndex = slot - 1 + (state.partySize === 10 ? state.targetGroup * 5 : 0)
|
const memberIndex = slot - 1 + (state.partySize > 6 ? state.targetGroup * 6 : 0)
|
||||||
return (
|
return (
|
||||||
<button onClick={() => sendAction(action)} type="button" key={action}>
|
<button onClick={() => sendAction(action)} type="button" key={action}>
|
||||||
<ControllerBindingLabel
|
<ControllerBindingLabel
|
||||||
@@ -293,13 +460,13 @@ export function DualScreenBottomDisplay() {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{state.partySize === 10 && (
|
{state.partySize > 6 && (
|
||||||
<button onClick={() => sendAction('toggleTargetGroup')} type="button">
|
<button onClick={() => sendAction('toggleTargetGroup')} type="button">
|
||||||
<ControllerBindingLabel
|
<ControllerBindingLabel
|
||||||
binding={state.bindings.toggleTargetGroup}
|
binding={state.bindings.toggleTargetGroup}
|
||||||
iconStyle={state.controllerIconStyle}
|
iconStyle={state.controllerIconStyle}
|
||||||
/>{' '}
|
/>{' '}
|
||||||
Party Group {state.targetGroup + 1}
|
Party Group {state.targetGroup + 1}/{Math.ceil(state.partySize / 6)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -396,14 +563,17 @@ export function DualScreenTopCombat({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="dual-top-party">
|
<section className="dual-top-party">
|
||||||
<div className={`dual-top-party-grid ${state.partySize === 10 ? 'raid' : ''}`}>
|
<div className={`dual-top-party-grid ${state.partySize > 6 ? 'raid' : ''}`}>
|
||||||
{state.party.map((member, index) => {
|
{state.party.map((member, index) => {
|
||||||
const partySlot = index + 1 + (state.directPartyTargeting && state.partySize === 10 ? state.targetGroup * 5 : 0)
|
const partySlot = (index % 6) + 1
|
||||||
const targetAction = `targetParty${partySlot}` as InputAction
|
const targetAction = `targetParty${partySlot}` as InputAction
|
||||||
const targetBinding = state.directPartyTargeting ? state.bindings[targetAction] : null
|
const groupStart = state.partySize > 6 ? state.targetGroup * 6 : 0
|
||||||
|
const inCurrentTargetGroup = index >= groupStart && index < groupStart + 6
|
||||||
|
const targetBinding = state.directPartyTargeting && inCurrentTargetGroup ? state.bindings[targetAction] : null
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
||||||
|
data-party-member-id={member.id}
|
||||||
key={member.id}
|
key={member.id}
|
||||||
onClick={() => onSelectTarget(member.id)}
|
onClick={() => onSelectTarget(member.id)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -418,6 +588,7 @@ export function DualScreenTopCombat({
|
|||||||
{member.shield > 0 && (
|
{member.shield > 0 && (
|
||||||
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
|
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
|
||||||
)}
|
)}
|
||||||
|
<em className="health-text">{Math.ceil(member.health)} / {member.maxHealth}</em>
|
||||||
</div>
|
</div>
|
||||||
{state.directPartyTargeting && targetBinding && (
|
{state.directPartyTargeting && targetBinding && (
|
||||||
<div className="member-target-key">
|
<div className="member-target-key">
|
||||||
@@ -437,11 +608,6 @@ export function DualScreenTopCombat({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer className="dual-top-log">
|
|
||||||
{state.log.slice(0, 3).map((entry) => (
|
|
||||||
<span className={entry.tone} key={entry.id}>{entry.text}</span>
|
|
||||||
))}
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-1
@@ -44,12 +44,16 @@ export type CombatLogEntry = {
|
|||||||
tone: 'system' | 'heal' | 'danger' | 'loot'
|
tone: 'system' | 'heal' | 'danger' | 'loot'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TANKLESS_DAMAGE_MULTIPLIER = 1.35
|
||||||
|
export const DEFAULT_GROUP_HEAL_TARGETS = 4
|
||||||
|
|
||||||
export const INITIAL_PARTY: PartyMember[] = [
|
export const INITIAL_PARTY: PartyMember[] = [
|
||||||
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
|
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
|
||||||
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
|
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
|
||||||
{ id: 'kael', name: 'Kael', role: 'Damage', health: 105, maxHealth: 105, shield: 0, hotTicks: 0 },
|
{ id: 'kael', name: 'Kael', role: 'Damage', health: 105, maxHealth: 105, shield: 0, hotTicks: 0 },
|
||||||
{ id: 'ves', name: 'Ves', role: 'Damage', health: 95, maxHealth: 95, shield: 0, hotTicks: 0 },
|
{ id: 'ves', name: 'Ves', role: 'Damage', health: 95, maxHealth: 95, shield: 0, hotTicks: 0 },
|
||||||
{ id: 'orin', name: 'Orin', role: 'Damage', health: 110, maxHealth: 110, shield: 0, hotTicks: 0 },
|
{ id: 'orin', name: 'Orin', role: 'Damage', health: 110, maxHealth: 110, shield: 0, hotTicks: 0 },
|
||||||
|
{ id: 'lyra', name: 'Lyra', role: 'Damage', health: 98, maxHealth: 98, shield: 0, hotTicks: 0 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const RAID_PARTY: PartyMember[] = [
|
export const RAID_PARTY: PartyMember[] = [
|
||||||
@@ -63,6 +67,14 @@ export const RAID_PARTY: PartyMember[] = [
|
|||||||
{ id: 'lyra', name: 'Lyra', role: 'Damage', health: 98, maxHealth: 98, shield: 0, hotTicks: 0 },
|
{ id: 'lyra', name: 'Lyra', role: 'Damage', health: 98, maxHealth: 98, shield: 0, hotTicks: 0 },
|
||||||
{ id: 'dax', name: 'Dax', role: 'Damage', health: 108, maxHealth: 108, shield: 0, hotTicks: 0 },
|
{ id: 'dax', name: 'Dax', role: 'Damage', health: 108, maxHealth: 108, shield: 0, hotTicks: 0 },
|
||||||
{ id: 'nyx', name: 'Nyx', role: 'Damage', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
|
{ id: 'nyx', name: 'Nyx', role: 'Damage', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
|
||||||
|
{ id: 'ren', name: 'Ren', role: 'Damage', health: 104, maxHealth: 104, shield: 0, hotTicks: 0 },
|
||||||
|
{ id: 'iona', name: 'Iona', role: 'Damage', health: 96, maxHealth: 96, shield: 0, hotTicks: 0 },
|
||||||
|
{ id: 'sol', name: 'Sol', role: 'Damage', health: 106, maxHealth: 106, shield: 0, hotTicks: 0 },
|
||||||
|
{ id: 'nari', name: 'Nari', role: 'Damage', health: 99, maxHealth: 99, shield: 0, hotTicks: 0 },
|
||||||
|
{ id: 'joss', name: 'Joss', role: 'Damage', health: 103, maxHealth: 103, shield: 0, hotTicks: 0 },
|
||||||
|
{ id: 'eira', name: 'Eira', role: 'Damage', health: 97, maxHealth: 97, shield: 0, hotTicks: 0 },
|
||||||
|
{ id: 'cato', name: 'Cato', role: 'Damage', health: 107, maxHealth: 107, shield: 0, hotTicks: 0 },
|
||||||
|
{ id: 'rhea', name: 'Rhea', role: 'Damage', health: 101, maxHealth: 101, shield: 0, hotTicks: 0 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const SPELLS: Spell[] = [
|
export const SPELLS: Spell[] = [
|
||||||
@@ -92,7 +104,7 @@ export const SPELLS: Spell[] = [
|
|||||||
id: 'radiance',
|
id: 'radiance',
|
||||||
key: '3',
|
key: '3',
|
||||||
name: 'Radiance',
|
name: 'Radiance',
|
||||||
description: 'Restores health to every living party member.',
|
description: 'Restores health to up to 4 injured party members.',
|
||||||
cost: 12,
|
cost: 12,
|
||||||
cooldown: 8,
|
cooldown: 8,
|
||||||
power: 18,
|
power: 18,
|
||||||
@@ -155,3 +167,28 @@ export const ENCOUNTERS: Encounter[] = [
|
|||||||
isBoss: true,
|
isBoss: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export function partyDamageOutput(party: PartyMember[], baseDamage: number) {
|
||||||
|
const livingCount = party.filter((member) => member.health > 0).length
|
||||||
|
return Math.round(baseDamage * (livingCount / Math.max(1, party.length)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tankPressureTargets(party: PartyMember[]) {
|
||||||
|
const living = party.filter((member) => member.health > 0)
|
||||||
|
const tanks = living.filter((member) => member.role === 'Tank')
|
||||||
|
if (tanks.length > 0) return { targets: tanks, multiplier: 1 }
|
||||||
|
const damageDealer = living
|
||||||
|
.filter((member) => member.role === 'Damage')
|
||||||
|
.sort((left, right) => right.health - left.health)[0]
|
||||||
|
return {
|
||||||
|
targets: damageDealer ? [damageDealer] : [],
|
||||||
|
multiplier: TANKLESS_DAMAGE_MULTIPLIER,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupHealTargets(party: PartyMember[], targetCount = DEFAULT_GROUP_HEAL_TARGETS) {
|
||||||
|
return party
|
||||||
|
.filter((member) => member.health > 0)
|
||||||
|
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))
|
||||||
|
.slice(0, targetCount)
|
||||||
|
}
|
||||||
|
|||||||
+636
-117
File diff suppressed because it is too large
Load Diff
@@ -12,4 +12,10 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-9
@@ -33,6 +33,7 @@ export const INPUT_ACTIONS = [
|
|||||||
'targetParty3',
|
'targetParty3',
|
||||||
'targetParty4',
|
'targetParty4',
|
||||||
'targetParty5',
|
'targetParty5',
|
||||||
|
'targetParty6',
|
||||||
'toggleTargetGroup',
|
'toggleTargetGroup',
|
||||||
'pause',
|
'pause',
|
||||||
] as const
|
] as const
|
||||||
@@ -60,6 +61,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
|
|||||||
targetParty3: 'Target Party Member 3',
|
targetParty3: 'Target Party Member 3',
|
||||||
targetParty4: 'Target Party Member 4',
|
targetParty4: 'Target Party Member 4',
|
||||||
targetParty5: 'Target Party Member 5',
|
targetParty5: 'Target Party Member 5',
|
||||||
|
targetParty6: 'Target Party Member 6',
|
||||||
toggleTargetGroup: 'Switch Raid Target Group',
|
toggleTargetGroup: 'Switch Raid Target Group',
|
||||||
pause: 'Pause Menu',
|
pause: 'Pause Menu',
|
||||||
}
|
}
|
||||||
@@ -85,6 +87,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
|||||||
targetParty3: 'F3',
|
targetParty3: 'F3',
|
||||||
targetParty4: 'F4',
|
targetParty4: 'F4',
|
||||||
targetParty5: 'F5',
|
targetParty5: 'F5',
|
||||||
|
targetParty6: 'F6',
|
||||||
toggleTargetGroup: 'Tab',
|
toggleTargetGroup: 'Tab',
|
||||||
pause: 'Escape',
|
pause: 'Escape',
|
||||||
},
|
},
|
||||||
@@ -108,6 +111,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
|||||||
targetParty3: 'Button15',
|
targetParty3: 'Button15',
|
||||||
targetParty4: 'Button13',
|
targetParty4: 'Button13',
|
||||||
targetParty5: 'Button4',
|
targetParty5: 'Button4',
|
||||||
|
targetParty6: 'Button11',
|
||||||
toggleTargetGroup: 'Button6',
|
toggleTargetGroup: 'Button6',
|
||||||
pause: 'Button9',
|
pause: 'Button9',
|
||||||
},
|
},
|
||||||
@@ -202,13 +206,19 @@ function bindingGroup(action: InputAction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isVisible(element: HTMLElement) {
|
function isVisible(element: HTMLElement) {
|
||||||
|
if (element.hidden || element.getAttribute('aria-hidden') === 'true') return false
|
||||||
return element.getClientRects().length > 0
|
return element.getClientRects().length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusableElements() {
|
function focusableElements() {
|
||||||
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
|
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
|
||||||
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
|
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
|
||||||
const scope: ParentNode = keyboard ?? pauseMenu ?? document
|
const dialog = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>(
|
||||||
|
'.result-screen, .binding-capture, .dual-startup-prompt',
|
||||||
|
),
|
||||||
|
).find(isVisible)
|
||||||
|
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
|
||||||
return Array.from(
|
return Array.from(
|
||||||
scope.querySelectorAll<HTMLElement>(
|
scope.querySelectorAll<HTMLElement>(
|
||||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||||
@@ -256,7 +266,14 @@ function moveFocus(action: InputAction) {
|
|||||||
const next = ranked[0]?.candidate
|
const next = ranked[0]?.candidate
|
||||||
if (!next) return
|
if (!next) return
|
||||||
next.focus({ preventScroll: true })
|
next.focus({ preventScroll: true })
|
||||||
next.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
}
|
||||||
|
|
||||||
|
function hasUiOverlay() {
|
||||||
|
return Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>(
|
||||||
|
'.pause-screen, .result-screen, .binding-capture, .dual-startup-prompt, .controller-keyboard',
|
||||||
|
),
|
||||||
|
).some(isVisible)
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUTTON_LABELS: Record<number, string> = {
|
const BUTTON_LABELS: Record<number, string> = {
|
||||||
@@ -416,18 +433,24 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
||||||
setLastDevice(device)
|
const uiOverlay = hasUiOverlay()
|
||||||
document.documentElement.dataset.inputDevice = device
|
|
||||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||||
|
|
||||||
|
setLastDevice(device)
|
||||||
|
document.documentElement.dataset.inputDevice = device
|
||||||
|
|
||||||
if (action.startsWith('navigate')) {
|
if (action.startsWith('navigate')) {
|
||||||
if (!combatActive) moveFocus(action)
|
if (uiOverlay || !combatActive) moveFocus(action)
|
||||||
} else if (action === 'confirm') {
|
} else if (action === 'confirm') {
|
||||||
const active = document.activeElement
|
const active = document.activeElement
|
||||||
if (isTextInput(active)) {
|
if (isTextInput(active)) {
|
||||||
setKeyboardInput(active)
|
setKeyboardInput(active)
|
||||||
window.requestAnimationFrame(() => focusFirstControl())
|
window.requestAnimationFrame(() => focusFirstControl())
|
||||||
} else if (active instanceof HTMLElement && active.matches('button, [role="button"]')) {
|
} else if (
|
||||||
|
active instanceof HTMLElement
|
||||||
|
&& active.matches('button:not(:disabled), [role="button"]')
|
||||||
|
&& isVisible(active)
|
||||||
|
) {
|
||||||
active.click()
|
active.click()
|
||||||
} else {
|
} else {
|
||||||
focusFirstControl()
|
focusFirstControl()
|
||||||
@@ -435,7 +458,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
} else if (action === 'back') {
|
} else if (action === 'back') {
|
||||||
if (keyboardInputRef.current) {
|
if (keyboardInputRef.current) {
|
||||||
closeKeyboard()
|
closeKeyboard()
|
||||||
} else if (!combatActive) {
|
} else if (uiOverlay || !combatActive) {
|
||||||
const backButton = Array.from(
|
const backButton = Array.from(
|
||||||
document.querySelectorAll<HTMLButtonElement>('.back-button:not(:disabled)'),
|
document.querySelectorAll<HTMLButtonElement>('.back-button:not(:disabled)'),
|
||||||
).find(isVisible)
|
).find(isVisible)
|
||||||
@@ -458,18 +481,28 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const combatActive = Boolean(
|
const combatActive = Boolean(
|
||||||
document.querySelector('[data-combat-active="true"]'),
|
document.querySelector('[data-combat-active="true"]'),
|
||||||
)
|
)
|
||||||
|
const uiOverlay = hasUiOverlay()
|
||||||
const menuDpadActions: Partial<Record<string, InputAction>> = {
|
const menuDpadActions: Partial<Record<string, InputAction>> = {
|
||||||
Button12: 'navigateUp',
|
Button12: 'navigateUp',
|
||||||
Button13: 'navigateDown',
|
Button13: 'navigateDown',
|
||||||
Button14: 'navigateLeft',
|
Button14: 'navigateLeft',
|
||||||
Button15: 'navigateRight',
|
Button15: 'navigateRight',
|
||||||
}
|
}
|
||||||
|
const uiPriority = [
|
||||||
|
'navigateUp',
|
||||||
|
'navigateDown',
|
||||||
|
'navigateLeft',
|
||||||
|
'navigateRight',
|
||||||
|
'confirm',
|
||||||
|
'back',
|
||||||
|
] satisfies InputAction[]
|
||||||
const directTargetActions = [
|
const directTargetActions = [
|
||||||
'targetParty1',
|
'targetParty1',
|
||||||
'targetParty2',
|
'targetParty2',
|
||||||
'targetParty3',
|
'targetParty3',
|
||||||
'targetParty4',
|
'targetParty4',
|
||||||
'targetParty5',
|
'targetParty5',
|
||||||
|
'targetParty6',
|
||||||
'toggleTargetGroup',
|
'toggleTargetGroup',
|
||||||
] satisfies InputAction[]
|
] satisfies InputAction[]
|
||||||
const combatPriority = [
|
const combatPriority = [
|
||||||
@@ -487,7 +520,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
'navigateLeft',
|
'navigateLeft',
|
||||||
'navigateRight',
|
'navigateRight',
|
||||||
] satisfies InputAction[]
|
] satisfies InputAction[]
|
||||||
const action = combatActive && preferencesRef.current.directPartyTargeting
|
const action = menuDpadActions[token] && (!combatActive || uiOverlay)
|
||||||
|
? menuDpadActions[token]
|
||||||
|
: uiOverlay
|
||||||
|
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
|
||||||
|
: combatActive && preferencesRef.current.directPartyTargeting
|
||||||
? [...directTargetActions, ...combatPriority].find(
|
? [...directTargetActions, ...combatPriority].find(
|
||||||
(candidate) => bindingsRef.current.controller[candidate] === token,
|
(candidate) => bindingsRef.current.controller[candidate] === token,
|
||||||
)
|
)
|
||||||
@@ -541,8 +578,13 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
const combatActive = document.querySelector('[data-combat-active="true"]')
|
const combatActive = document.querySelector('[data-combat-active="true"]')
|
||||||
if (combatActive) return
|
if (combatActive) return
|
||||||
|
const candidates = focusableElements()
|
||||||
|
const active = document.activeElement
|
||||||
|
const activeIsUsable = active instanceof HTMLElement
|
||||||
|
&& candidates.includes(active)
|
||||||
|
&& isVisible(active)
|
||||||
if (
|
if (
|
||||||
document.activeElement === document.body
|
(!activeIsUsable || document.activeElement === document.body)
|
||||||
&& !keyboardInputRef.current
|
&& !keyboardInputRef.current
|
||||||
&& !captureRef.current
|
&& !captureRef.current
|
||||||
) {
|
) {
|
||||||
@@ -553,6 +595,8 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
window.requestAnimationFrame(ensureFocus)
|
window.requestAnimationFrame(ensureFocus)
|
||||||
})
|
})
|
||||||
observer.observe(document.getElementById('root') ?? document.body, {
|
observer.observe(document.getElementById('root') ?? document.body, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'],
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: true,
|
subtree: true,
|
||||||
})
|
})
|
||||||
|
|||||||
+16
-2
@@ -1,9 +1,10 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { InputProvider } from './input.tsx'
|
import { InputProvider } from './input.tsx'
|
||||||
import { DualScreenBottomDisplay, DualScreenProvider } from './dualScreen.tsx'
|
import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt } from './dualScreen.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
@@ -11,6 +12,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<DualScreenBottomDisplay />
|
<DualScreenBottomDisplay />
|
||||||
) : (
|
) : (
|
||||||
<DualScreenProvider>
|
<DualScreenProvider>
|
||||||
|
<DualScreenStartupPrompt />
|
||||||
<InputProvider>
|
<InputProvider>
|
||||||
<App />
|
<App />
|
||||||
</InputProvider>
|
</InputProvider>
|
||||||
@@ -19,7 +21,19 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
const isNativeApp = Capacitor.isNativePlatform()
|
||||||
|
|
||||||
|
if (import.meta.env.PROD && isNativeApp && 'serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.getRegistrations()
|
||||||
|
.then((registrations) => Promise.all(registrations.map((registration) => registration.unregister())))
|
||||||
|
.then(() => caches.keys())
|
||||||
|
.then((keys) => Promise.all(keys.filter((key) => key.startsWith('chronicle-')).map((key) => caches.delete(key))))
|
||||||
|
.catch(() => {
|
||||||
|
// Native app assets should come directly from the APK when cache cleanup is unavailable.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.PROD && !isNativeApp && 'serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('/service-worker.js').catch(() => {
|
navigator.serviceWorker.register('/service-worker.js').catch(() => {
|
||||||
// Offline launch remains optional when registration is unavailable.
|
// Offline launch remains optional when registration is unavailable.
|
||||||
|
|||||||
+1594
-6581
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -59,7 +59,7 @@ export type Item = {
|
|||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
slot: EquipmentSlot
|
slot: EquipmentSlot
|
||||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic'
|
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||||
itemLevel: number
|
itemLevel: number
|
||||||
healingPower: number
|
healingPower: number
|
||||||
maxResourceBonus: number
|
maxResourceBonus: number
|
||||||
@@ -234,6 +234,7 @@ export type Account = {
|
|||||||
export type AuthSession = {
|
export type AuthSession = {
|
||||||
account: Account | null
|
account: Account | null
|
||||||
profile: CharacterProfile | null
|
profile: CharacterProfile | null
|
||||||
|
token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BonusItem = {
|
export type BonusItem = {
|
||||||
@@ -247,6 +248,7 @@ export type BonusItem = {
|
|||||||
maxResourceBonus: number
|
maxResourceBonus: number
|
||||||
glyph: string
|
glyph: string
|
||||||
description: string
|
description: string
|
||||||
|
quantity: number
|
||||||
duplicate: boolean
|
duplicate: boolean
|
||||||
quantityAfter: number
|
quantityAfter: number
|
||||||
}
|
}
|
||||||
@@ -338,6 +340,8 @@ export async function completeRoguelike(
|
|||||||
options?: {
|
options?: {
|
||||||
bossesCleared?: number
|
bossesCleared?: number
|
||||||
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
||||||
|
lootSourceEncounterId?: number
|
||||||
|
roguelikeStage?: number
|
||||||
},
|
},
|
||||||
): Promise<DungeonReward> {
|
): Promise<DungeonReward> {
|
||||||
return activeGameRepository().completeRoguelike(
|
return activeGameRepository().completeRoguelike(
|
||||||
@@ -374,6 +378,10 @@ export async function craftItem(recipeId: number): Promise<CharacterProfile> {
|
|||||||
return activeGameRepository().craftItem(recipeId)
|
return activeGameRepository().craftItem(recipeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function upgradeItem(itemId: number): Promise<CharacterProfile> {
|
||||||
|
return activeGameRepository().upgradeItem(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
export async function rollEncounterLoot(
|
export async function rollEncounterLoot(
|
||||||
encounterId: number,
|
encounterId: number,
|
||||||
difficultyId: number,
|
difficultyId: number,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type CpuPvpLeaderboardEntry = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
|
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
|
||||||
|
const checkpointKey = 'chronicle.pvpRoguelikeCheckpoint.v1'
|
||||||
|
|
||||||
export function randomCpuDifficulty(): CpuDifficulty {
|
export function randomCpuDifficulty(): CpuDifficulty {
|
||||||
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
|
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
|
||||||
@@ -44,3 +45,24 @@ export function recordCpuPvpLeaderboard(entry: CpuPvpLeaderboardEntry) {
|
|||||||
.slice(0, 30)
|
.slice(0, 30)
|
||||||
localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next))
|
localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkpointStorageKey(characterId: number, contentType: PvpContentType) {
|
||||||
|
return `${checkpointKey}:${characterId}:${contentType}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPvpRoguelikeCheckpoint(characterId: number, contentType: PvpContentType) {
|
||||||
|
const value = Number(localStorage.getItem(checkpointStorageKey(characterId, contentType)) ?? 1)
|
||||||
|
return Number.isInteger(value) && value >= 5 ? value : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordPvpRoguelikeCheckpoint(
|
||||||
|
characterId: number,
|
||||||
|
contentType: PvpContentType,
|
||||||
|
stage: number,
|
||||||
|
) {
|
||||||
|
if (stage < 5 || stage % 5 !== 0) return loadPvpRoguelikeCheckpoint(characterId, contentType)
|
||||||
|
const current = loadPvpRoguelikeCheckpoint(characterId, contentType)
|
||||||
|
const next = Math.max(current, stage)
|
||||||
|
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"files": ["vite.config.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user