Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 814eb1998d | |||
| 7fe62d8c82 | |||
| 3a8d5ad8c5 | |||
| a604569a2f |
@@ -0,0 +1,6 @@
|
||||
# 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.
|
||||
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
||||
- Apply game changes to both web version and mobile app version.
|
||||
+203
@@ -43,6 +43,209 @@ 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
|
||||
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`. The startup command installs
|
||||
dependencies, applies schema migrations, 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Then restart the TrueNAS app.
|
||||
|
||||
### 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
|
||||
|
||||
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.
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.
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.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode 42
|
||||
versionName "1.0.25"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// 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 {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
|
||||
@@ -2,17 +2,25 @@ package com.warren.iwanttoheal;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.SystemClock;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import java.io.File;
|
||||
|
||||
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
|
||||
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
|
||||
private static final long DPAD_THROTTLE_MS = 125;
|
||||
private long lastDpadDispatchAt = 0;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
clearWebViewServiceWorkers();
|
||||
super.onCreate(savedInstanceState);
|
||||
if (bridge != null) {
|
||||
bridge.getWebView().clearCache(true);
|
||||
}
|
||||
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
|
||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||
String token = controllerToken(event.getKeyCode());
|
||||
@@ -54,6 +81,7 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
|
||||
if (event.getAction() == KeyEvent.ACTION_DOWN) {
|
||||
boolean repeat = event.getRepeatCount() > 0;
|
||||
if (isDpadToken(token) && shouldThrottleDpad()) return true;
|
||||
String script =
|
||||
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
|
||||
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
||||
@@ -64,6 +92,20 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
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) {
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_BUTTON_A:
|
||||
|
||||
@@ -66,9 +66,10 @@ public class DualScreenPlugin extends Plugin {
|
||||
}
|
||||
|
||||
String gameUrl = bridge.getLocalUrl();
|
||||
String topGameUrl = gameUrl + "/?display=top";
|
||||
String controlsUrl = gameUrl + "/?display=bottom";
|
||||
String targetUrl = launchPlan.targetIsTopDisplay ? gameUrl : controlsUrl;
|
||||
String currentUrl = launchPlan.currentIsTopDisplay ? gameUrl : controlsUrl;
|
||||
String targetUrl = launchPlan.targetIsTopDisplay ? topGameUrl : controlsUrl;
|
||||
String currentUrl = launchPlan.currentIsTopDisplay ? topGameUrl : controlsUrl;
|
||||
|
||||
closePresentation();
|
||||
presentation = new TopDisplayPresentation(
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS dungeons (
|
||||
name TEXT NOT NULL,
|
||||
recommended_level INTEGER NOT NULL DEFAULT 1,
|
||||
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,
|
||||
experience_reward INTEGER NOT NULL DEFAULT 100,
|
||||
description TEXT NOT NULL
|
||||
|
||||
+330
-19
@@ -5,8 +5,8 @@ INSERT OR IGNORE INTO locations (id, slug, name, description) VALUES
|
||||
INSERT OR IGNORE INTO dungeons
|
||||
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
|
||||
VALUES
|
||||
(1, 1, 'ashen-halls', 'The Ashen Halls', 1, 'dungeon', 5, 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.');
|
||||
(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', 18, 10, 175, 'Lead eighteen allies through the caldera and break the Ember Crown across three phases.');
|
||||
|
||||
UPDATE dungeons
|
||||
SET slug = 'bulldrome-hunting-ground',
|
||||
@@ -14,12 +14,12 @@ SET slug = 'bulldrome-hunting-ground',
|
||||
location_id = 1,
|
||||
recommended_level = 1,
|
||||
content_type = 'dungeon',
|
||||
party_size = 5,
|
||||
party_size = 6,
|
||||
completion_item_level = NULL,
|
||||
experience_reward = 125,
|
||||
description = 'A three-boss hunt featuring Bulldrome, Yian Kut-Ku, and Rathian.'
|
||||
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';
|
||||
|
||||
INSERT OR IGNORE INTO difficulties
|
||||
@@ -30,7 +30,7 @@ VALUES
|
||||
(3, 'champion', 'Champion', 15, 10, 1.7, 1.45, 2.2, 'Demanding encounters for developed characters.'),
|
||||
(4, 'mythic', 'Mythic', 20, 15, 2.1, 1.75, 3.0, 'Endgame dungeon difficulty.'),
|
||||
(5, 'ascendant', 'Ascendant', 25, 20, 2.6, 2.1, 4.0, '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
|
||||
dropped_item_level = CASE slug
|
||||
@@ -108,7 +108,7 @@ VALUES
|
||||
(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.'),
|
||||
(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.'),
|
||||
(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.'),
|
||||
@@ -429,6 +429,168 @@ 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, 104, 1, 1), (3, 108, 1, 1), (3, 105, 1, 1), (3, 109, 1, 1), (3, 106, 1, 1), (3, 107, 1, 0);
|
||||
|
||||
-- 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 5 THEN 1
|
||||
WHEN 10 THEN 2
|
||||
WHEN 15 THEN 3
|
||||
WHEN 20 THEN 4
|
||||
WHEN 25 THEN 5
|
||||
ELSE difficulty_id
|
||||
END
|
||||
WHERE id BETWEEN 1001 AND 1409;
|
||||
|
||||
UPDATE items
|
||||
SET rarity = CASE item_level
|
||||
WHEN 5 THEN 'common'
|
||||
WHEN 10 THEN 'uncommon'
|
||||
WHEN 15 THEN 'rare'
|
||||
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 5 THEN ''
|
||||
WHEN 10 THEN 'Green '
|
||||
WHEN 15 THEN 'Blue '
|
||||
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 5 THEN ''
|
||||
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 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
|
||||
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
|
||||
VALUES
|
||||
@@ -611,7 +773,7 @@ SET slug = 'tigrex-raid',
|
||||
location_id = 3,
|
||||
recommended_level = 5,
|
||||
content_type = 'raid',
|
||||
party_size = 10,
|
||||
party_size = 18,
|
||||
completion_item_level = NULL,
|
||||
experience_reward = 275,
|
||||
description = 'A raid-scale hunt against Tigrex, Rathalos, and Gypceros.'
|
||||
@@ -620,13 +782,13 @@ WHERE id = 2;
|
||||
INSERT OR IGNORE INTO dungeons
|
||||
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
|
||||
VALUES
|
||||
(3, 3, 'tigrex-hunting-ground', 'Tigrex Hunting Ground', 5, 'dungeon', 5, 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.'),
|
||||
(5, 3, 'nargacuga-raid', 'Nargacuga Raid', 10, 'raid', 10, 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.'),
|
||||
(7, 3, 'barroth-raid', 'Barroth Raid', 15, 'raid', 10, 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.'),
|
||||
(9, 3, 'anjanath-raid', 'Anjanath Raid', 20, 'raid', 10, NULL, 425, 'A raid-scale hunt against Anjanath, Bazelgeuse, and Odogaron.');
|
||||
(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', 6, NULL, 245, 'A three-boss hunt featuring 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', 6, NULL, 285, 'A three-boss hunt featuring 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', 6, NULL, 325, 'A three-boss hunt featuring 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
|
||||
SET dropped_item_level = 10,
|
||||
@@ -678,9 +840,9 @@ SET slug = CASE id
|
||||
END,
|
||||
encounter_type = CASE WHEN id IN (102, 105, 108) THEN 'boss' ELSE 'trash' END,
|
||||
description = CASE id
|
||||
WHEN 102 THEN 'Tigrex drops monster parts for item level 10 crafting.'
|
||||
WHEN 105 THEN 'Rathalos drops monster parts for item level 10 crafting.'
|
||||
WHEN 108 THEN 'Gypceros 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 boss coins for item level 10 crafting.'
|
||||
WHEN 108 THEN 'Gypceros drops boss coins for item level 10 crafting.'
|
||||
ELSE 'Hunters clear the raid path.'
|
||||
END
|
||||
WHERE id BETWEEN 100 AND 108;
|
||||
@@ -702,7 +864,7 @@ SELECT
|
||||
offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8,
|
||||
offset.party_damage + generated_bosses.boss_index * 3,
|
||||
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 || '.'
|
||||
END
|
||||
FROM generated_loot_tiers
|
||||
@@ -730,7 +892,7 @@ SELECT
|
||||
offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8,
|
||||
offset.party_damage + generated_bosses.boss_index * 3 + 24,
|
||||
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 || '.'
|
||||
END
|
||||
FROM generated_loot_tiers
|
||||
@@ -1011,3 +1173,152 @@ INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
||||
(1007, 868, 5), (1007, 869, 3), (1007, 871, 1),
|
||||
(1008, 868, 5), (1008, 869, 3), (1008, 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 5 THEN 1
|
||||
WHEN 10 THEN 2
|
||||
WHEN 15 THEN 3
|
||||
WHEN 20 THEN 4
|
||||
WHEN 25 THEN 5
|
||||
ELSE difficulty_id
|
||||
END
|
||||
WHERE id BETWEEN 1001 AND 1409;
|
||||
|
||||
UPDATE items
|
||||
SET rarity = CASE item_level
|
||||
WHEN 5 THEN 'common'
|
||||
WHEN 10 THEN 'uncommon'
|
||||
WHEN 15 THEN 'rare'
|
||||
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 5 THEN ''
|
||||
WHEN 10 THEN 'Green '
|
||||
WHEN 15 THEN 'Blue '
|
||||
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 5 THEN ''
|
||||
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 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,172 @@
|
||||
# Gearing System
|
||||
|
||||
## Goal
|
||||
|
||||
Gearing should move from boss-specific multi-item drop tables to one clear currency loop:
|
||||
|
||||
1. Kill bosses.
|
||||
2. Earn boss coins.
|
||||
3. Craft gear with those coins.
|
||||
4. Upgrade that boss gear into the next item-level tier with higher-rarity coins.
|
||||
|
||||
This keeps boss loot readable, removes low-percentage frustration, and makes every boss kill progress a targeted gear goal.
|
||||
|
||||
## Coin Tiers
|
||||
|
||||
Coins are component items. Each coin is tied to a boss source and an item-level tier.
|
||||
|
||||
| Item Level | Display Color | Rarity Key | Example |
|
||||
| --- | --- | --- | --- |
|
||||
| 5 | White | common | Bulldrome Coin |
|
||||
| 10 | Green | uncommon | Green Bulldrome Coin |
|
||||
| 15 | Blue | rare | Blue Bulldrome Coin |
|
||||
| 20 | Purple | epic | Purple Bulldrome Coin |
|
||||
| 25 | Orange | legendary | Orange Bulldrome Coin |
|
||||
|
||||
Implementation note: the current TypeScript rarity union supports `common`, `uncommon`, `rare`, and `epic`. Orange needs a new rarity key, recommended as `legendary`, plus UI color styling.
|
||||
|
||||
## Boss Loot
|
||||
|
||||
Each boss has one loot roll.
|
||||
|
||||
For now, each successful boss loot roll awards 1 to 3 coins:
|
||||
|
||||
| Roll Result | Coins Awarded |
|
||||
| --- | --- |
|
||||
| Low roll | 1 coin |
|
||||
| Normal roll | 2 coins |
|
||||
| High roll | 3 coins |
|
||||
|
||||
Recommended weighting:
|
||||
|
||||
| Coins | Chance |
|
||||
| --- | --- |
|
||||
| 1 | 50% |
|
||||
| 2 | 35% |
|
||||
| 3 | 15% |
|
||||
|
||||
The coin source comes from the defeated boss. Bulldrome drops Bulldrome coins, Rathian drops Rathian coins, and so on.
|
||||
|
||||
The coin tier comes from content difficulty or roguelike depth:
|
||||
|
||||
| Source | Coin Tier |
|
||||
| --- | --- |
|
||||
| Item level 5 content | White level 5 coins |
|
||||
| Item level 10 content | Green level 10 coins |
|
||||
| Item level 15 content | Blue level 15 coins |
|
||||
| Item level 20 content | Purple level 20 coins |
|
||||
| Item level 25 content | Orange level 25 coins |
|
||||
|
||||
## Crafting Costs
|
||||
|
||||
Gear is crafted with boss coins from the same boss and item-level tier.
|
||||
|
||||
| Gear Item Level | Cost |
|
||||
| --- | --- |
|
||||
| 5 | 5 white boss coins |
|
||||
| 10 | 10 green boss coins |
|
||||
| 15 | 15 blue boss coins |
|
||||
| 20 | 20 purple boss coins |
|
||||
| 25 | 25 orange boss coins |
|
||||
|
||||
Example:
|
||||
|
||||
- Bulldrome item-level 5 helmet costs 5 white Bulldrome coins.
|
||||
- Bulldrome item-level 10 helmet costs 10 green Bulldrome coins.
|
||||
- Rathian item-level 20 gloves cost 20 purple Rathian coins.
|
||||
|
||||
## Gear Upgrades
|
||||
|
||||
Crafting can create gear directly, but upgrades should become the preferred long-term path.
|
||||
|
||||
Upgrade rule:
|
||||
|
||||
- Existing boss gear upgrades into the next item-level version of the same boss gear.
|
||||
- Upgrade cost uses coins from the next tier.
|
||||
- Required coin quantity equals the target item level.
|
||||
|
||||
Examples:
|
||||
|
||||
| Upgrade | Cost |
|
||||
| --- | --- |
|
||||
| Bulldrome item level 5 gear -> Bulldrome item level 10 gear | 10 green Bulldrome coins |
|
||||
| Bulldrome item level 10 gear -> Bulldrome item level 15 gear | 15 blue Bulldrome coins |
|
||||
| Bulldrome item level 15 gear -> Bulldrome item level 20 gear | 20 purple Bulldrome coins |
|
||||
| Bulldrome item level 20 gear -> Bulldrome item level 25 gear | 25 orange Bulldrome coins |
|
||||
|
||||
Upgrade should consume the old item and award the upgraded item. This avoids duplicate clutter and keeps equipment identity clear.
|
||||
|
||||
## Roguelike Loot
|
||||
|
||||
Roguelike bosses should award coins when defeated, using the same 1 to 3 coin roll.
|
||||
|
||||
Roguelike coin tier should scale by wave band:
|
||||
|
||||
| Waves | Coin Tier |
|
||||
| --- | --- |
|
||||
| 1-4 | Level 5 white coins |
|
||||
| 5-9 | Level 10 green coins |
|
||||
| 10-14 | Level 15 blue coins |
|
||||
| 15-19 | Level 20 purple coins |
|
||||
| 20+ | Level 25 orange coins |
|
||||
|
||||
Boss identity can be handled two ways:
|
||||
|
||||
1. Boss-based coins: use the actual boss template selected for that roguelike boss.
|
||||
2. Roguelike coins: use a generic roguelike coin per tier.
|
||||
|
||||
Recommended first pass: boss-based coins. It reuses the same crafting economy as dungeons and makes roguelike runs feel connected to the main gear chase.
|
||||
|
||||
## Roguelike Checkpoints
|
||||
|
||||
Checkpoints should unlock every 5 waves.
|
||||
|
||||
| Highest Cleared Wave | Future Start Wave |
|
||||
| --- | --- |
|
||||
| 0-4 | 1 |
|
||||
| 5-9 | 5 |
|
||||
| 10-14 | 10 |
|
||||
| 15-19 | 15 |
|
||||
| 20+ | Highest unlocked 5-wave checkpoint |
|
||||
|
||||
Checkpoint rule:
|
||||
|
||||
- Unlock a checkpoint after clearing its boss band.
|
||||
- Starting from a checkpoint begins at that wave band with matching coin tier.
|
||||
- Runs should still record leaderboard progress from the selected start wave so full runs and checkpoint runs can be ranked separately later.
|
||||
|
||||
Current implementation note: the roguelike screen always starts at stage 1 and only awards XP per boss. Checkpoints need saved character progress and a start-wave selector.
|
||||
|
||||
## Current Code Fit
|
||||
|
||||
The existing system already has most of the required foundation:
|
||||
|
||||
- `items.slot = 'component'` can represent coins.
|
||||
- `character_inventory.quantity` already stacks components.
|
||||
- `crafting_recipes` and `crafting_recipe_components` already support coin costs.
|
||||
- `encounter_loot_rolls` and `encounter_loot_roll_items` already persist retry-safe loot awards.
|
||||
- `completeRoguelike` is already called after each roguelike boss kill for XP, so coin awards can attach to that same flow.
|
||||
|
||||
Needed changes:
|
||||
|
||||
- Replace current 4-component boss drop tables with one boss coin per boss per tier.
|
||||
- Change boss loot roll count from multiple chance slots to one 1-3 coin roll.
|
||||
- Add orange/legendary rarity support.
|
||||
- Add upgrade recipes or a dedicated upgrade endpoint.
|
||||
- Add roguelike boss coin awards.
|
||||
- Add roguelike checkpoint persistence and start-wave selection.
|
||||
- Export updated offline starter data after seed changes.
|
||||
|
||||
## Suggestions
|
||||
|
||||
Use guaranteed coin drops for now. One to three coins per boss gives steady progress and makes craft timing easy to understand.
|
||||
|
||||
Keep coins boss-specific, not slot-specific. Slot-specific components add complexity without much decision value.
|
||||
|
||||
Use upgrade-first UI. Show the next upgrade for equipped gear before showing the full crafting catalog.
|
||||
|
||||
Keep direct crafting and upgrading at the same coin cost for the target tier. Direct crafting helps new slots catch up; upgrading preserves boss gear identity.
|
||||
|
||||
Add a pity floor only if needed later. If boss kills always award coins, the system already has deterministic progress.
|
||||
|
||||
Use one orange rarity key: `legendary`. Avoid storing display color names as rarity values; colors can change without data migration.
|
||||
+5
-2
@@ -6,16 +6,19 @@
|
||||
"scripts": {
|
||||
"predev": "npm run db:init",
|
||||
"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:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:sync",
|
||||
"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",
|
||||
"db:backup": "node scripts/backup-db.mjs",
|
||||
"db:init": "node scripts/init-db.mjs",
|
||||
"offline:export": "node scripts/export-offline-profile.mjs",
|
||||
"lint": "eslint .",
|
||||
"admin:start": "node server/admin.mjs",
|
||||
"auth:start": "node server/auth.mjs",
|
||||
"start": "node server/production.mjs",
|
||||
"prepreview": "npm run db:init",
|
||||
"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}`)
|
||||
})
|
||||
+659
-60
@@ -33,6 +33,31 @@ function sendJson(response, status, body, headers = {}) {
|
||||
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) {
|
||||
const chunks = []
|
||||
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) {
|
||||
const secure = request.headers['x-forwarded-proto'] === 'https'
|
||||
|| Boolean(request.socket.encrypted)
|
||||
@@ -284,7 +320,7 @@ function createSession(database, accountId, ip, activeCharacterId) {
|
||||
}
|
||||
|
||||
function currentSession(database, request) {
|
||||
const token = parseCookies(request)[sessionCookieName]
|
||||
const token = requestSessionToken(request)
|
||||
if (!token) return null
|
||||
return database.prepare(`
|
||||
SELECT
|
||||
@@ -854,6 +890,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) {
|
||||
return database.prepare(`
|
||||
SELECT
|
||||
@@ -937,11 +1304,57 @@ function formatLootRoll(database, context, record, dropChance) {
|
||||
}
|
||||
}
|
||||
|
||||
function componentDropQuantity(droppedItemLevel) {
|
||||
const tier = Math.max(0, Math.floor((droppedItemLevel - 5) / 5))
|
||||
const secondChance = Math.min(0.85, 0.35 + tier * 0.12)
|
||||
const thirdChance = Math.min(0.6, 0.1 + tier * 0.1)
|
||||
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0)
|
||||
function coinDropQuantity() {
|
||||
const roll = Math.random()
|
||||
if (roll < 0.15) return 3
|
||||
if (roll < 0.5) return 2
|
||||
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) {
|
||||
@@ -1044,13 +1457,11 @@ function rollEncounterLoot(database, characterId, encounterId, difficultyId, run
|
||||
}
|
||||
|
||||
const selectedQuantities = new Map()
|
||||
const lootChanceSlots = context.contentType === 'raid' ? 8 : 5
|
||||
for (let index = 0; index < lootChanceSlots; index += 1) {
|
||||
if (Math.random() >= dropChance) continue
|
||||
if (Math.random() < dropChance) {
|
||||
const selected = rollWeightedLootEntry(entries)
|
||||
selectedQuantities.set(
|
||||
selected.id,
|
||||
(selectedQuantities.get(selected.id) ?? 0) + componentDropQuantity(context.droppedItemLevel),
|
||||
coinDropQuantity(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1334,6 +1745,102 @@ function craftItem(database, characterId, recipeId) {
|
||||
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 = ?
|
||||
`).get(currentRecipe.sourceEncounterId, item.slot, item.itemLevel + 5)
|
||||
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) {
|
||||
const character = database.prepare(`
|
||||
SELECT class_id AS classId, talent_points AS talentPoints
|
||||
@@ -1622,7 +2129,7 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
||||
ON CONFLICT(character_id, item_id)
|
||||
DO UPDATE SET quantity = quantity + 1
|
||||
`).run(characterId, bonusItem.id)
|
||||
bonusItem = { ...bonusItem, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
|
||||
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1777,6 +2284,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
SET experience = ?, level = ?, talent_points = ?
|
||||
WHERE id = ?
|
||||
`).run(newExperience, newLevel, newTalentPoints, characterId)
|
||||
const bonusItem = awardRoguelikeCoin(
|
||||
database,
|
||||
characterId,
|
||||
Number(runMetrics?.lootSourceEncounterId),
|
||||
Number(runMetrics?.roguelikeStage),
|
||||
)
|
||||
|
||||
return {
|
||||
dungeonName: `${dungeon.name} Roguelike`,
|
||||
@@ -1791,7 +2304,7 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
durationSeconds,
|
||||
averageItemLevel,
|
||||
unlockedAbilities,
|
||||
bonusItem: null,
|
||||
bonusItem,
|
||||
profile: getProfile(database, characterId, accountId),
|
||||
}
|
||||
}
|
||||
@@ -1880,12 +2393,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) {
|
||||
if (!request.url?.startsWith('/api/')) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
sendCorsPreflight(request, response)
|
||||
return
|
||||
}
|
||||
|
||||
setCorsHeaders(response, request)
|
||||
|
||||
if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') {
|
||||
sendBossImage(request, response)
|
||||
return
|
||||
@@ -1911,59 +2536,23 @@ export async function handleApiRequest(request, response, next) {
|
||||
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
||||
`).run()
|
||||
|
||||
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 },
|
||||
{ '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) },
|
||||
)
|
||||
if (await handleAuthApiRoute(database, request, response)) {
|
||||
return
|
||||
}
|
||||
|
||||
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') {
|
||||
sendJson(response, 200, getProfile(database, session.characterId, session.accountId))
|
||||
return
|
||||
@@ -2059,6 +2648,16 @@ export async function handleApiRequest(request, response, next) {
|
||||
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$/)
|
||||
if (encounterLootRoll && request.method === 'POST') {
|
||||
const payload = await readJson(request)
|
||||
|
||||
+625
-30
@@ -310,6 +310,7 @@ textarea:focus-visible,
|
||||
}
|
||||
|
||||
.binding-capture,
|
||||
.dual-startup-prompt,
|
||||
.controller-keyboard-backdrop {
|
||||
align-items: center;
|
||||
background: rgba(5, 6, 9, 0.88);
|
||||
@@ -320,7 +321,8 @@ textarea:focus-visible,
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.binding-capture > div {
|
||||
.binding-capture > div,
|
||||
.dual-startup-prompt > section {
|
||||
background: var(--panel);
|
||||
border: 3px solid #090a0d;
|
||||
box-shadow: 8px 8px 0 #050609;
|
||||
@@ -337,7 +339,29 @@ textarea:focus-visible,
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.dual-startup-prompt p:not(.eyebrow) {
|
||||
color: var(--muted);
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.dual-startup-prompt small {
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.dual-startup-prompt div {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.binding-capture button,
|
||||
.dual-startup-prompt button,
|
||||
.controller-keyboard button {
|
||||
background: #242630;
|
||||
border: 2px solid #090a0d;
|
||||
@@ -351,6 +375,17 @@ textarea:focus-visible,
|
||||
padding: 8px 24px;
|
||||
}
|
||||
|
||||
.dual-startup-prompt button {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 8px;
|
||||
min-height: 48px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.dual-startup-prompt button:first-child {
|
||||
outline-color: var(--green);
|
||||
}
|
||||
|
||||
.controller-keyboard {
|
||||
background: var(--panel);
|
||||
border: 3px solid #090a0d;
|
||||
@@ -572,11 +607,12 @@ textarea:focus-visible,
|
||||
.dual-top-party-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dual-top-party-grid.raid {
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dual-top-member {
|
||||
@@ -638,8 +674,34 @@ textarea:focus-visible,
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dual-top-party-grid.raid .dual-top-member {
|
||||
min-height: 72px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.dual-top-party-grid.raid .member-header {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.dual-top-party-grid.raid .member-header strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dual-top-party-grid.raid .member-header small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dual-top-party-grid.raid .dual-top-member .bar {
|
||||
height: 18px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.dual-top-party-grid.raid .member-effects {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.dual-top-log {
|
||||
display: flex;
|
||||
display: none;
|
||||
gap: 14px;
|
||||
min-height: 36px;
|
||||
overflow: hidden;
|
||||
@@ -711,7 +773,7 @@ textarea:focus-visible,
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
height: calc(100dvh - 20px);
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -727,7 +789,7 @@ textarea:focus-visible,
|
||||
}
|
||||
|
||||
.dual-top-main .dual-top-party-grid.raid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dual-top-main .dual-top-member {
|
||||
@@ -980,8 +1042,13 @@ textarea:focus-visible,
|
||||
}
|
||||
|
||||
.game-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
width: min(1180px, calc(100% - 28px));
|
||||
margin: 22px auto;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
padding: 12px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1289,11 +1356,27 @@ h2 {
|
||||
.menu-screen,
|
||||
.content-screen,
|
||||
.message-panel {
|
||||
margin-top: 18px;
|
||||
flex: 1;
|
||||
margin-top: 12px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.content-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menu-screen {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.content-screen > .screen-heading {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.message-panel {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -1411,6 +1494,30 @@ h2 {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cloud-sync-card {
|
||||
cursor: default;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.cloud-sync-card:hover {
|
||||
outline-color: #42414c;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.cloud-sync-card > div {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cloud-sync-card .text-button:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cloud-sync-message {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.menu-card > span,
|
||||
.class-portrait {
|
||||
align-items: center;
|
||||
@@ -1466,6 +1573,130 @@ h2 {
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.settings-tabs,
|
||||
.talent-page-tabs {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.settings-tabs button,
|
||||
.talent-page-tabs button {
|
||||
background: #15161c;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 8px;
|
||||
min-height: 42px;
|
||||
outline: 2px solid #41404a;
|
||||
padding: 8px 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-tabs button.selected,
|
||||
.settings-tabs button:hover,
|
||||
.talent-page-tabs button.active,
|
||||
.talent-page-tabs button:hover {
|
||||
color: var(--gold);
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.settings-screen,
|
||||
.equipment-screen,
|
||||
.talent-screen,
|
||||
.customize-screen {
|
||||
height: calc(100dvh - 92px);
|
||||
}
|
||||
|
||||
.settings-tab-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-bindings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-bindings-panel .settings-heading,
|
||||
.settings-bindings-panel .binding-tabs,
|
||||
.settings-bindings-panel .settings-footer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.settings-bindings-panel .binding-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.equipment-screen .gear-summary,
|
||||
.equipment-screen .equipment-tabs,
|
||||
.equipment-screen .item-comparison,
|
||||
.equipment-screen .equipment-footer,
|
||||
.talent-screen .talent-toolbar,
|
||||
.talent-screen .talent-page-tabs,
|
||||
.talent-screen .talent-footer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.equipment-screen .equipment-layout,
|
||||
.equipment-screen .crafting-panel,
|
||||
.talent-screen .talent-tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.talent-screen .talent-tree {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embedded-screen {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.customize-screen > .customize-tabs {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.customize-screen > .customize-layout,
|
||||
.customize-screen > .embedded-screen {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.customize-screen .loadout-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.customize-screen .ability-library {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.customize-screen .class-picker {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loot-preview-grid,
|
||||
.leaderboard-table {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.combat-header-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -2341,6 +2572,13 @@ h2 {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.equipped-panel,
|
||||
.inventory-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.equipment-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -2382,6 +2620,8 @@ h2 {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 13px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.equipment-slots > button {
|
||||
@@ -2437,10 +2677,12 @@ h2 {
|
||||
|
||||
.inventory-list {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
margin-top: 13px;
|
||||
max-height: 442px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
@@ -2482,10 +2724,43 @@ h2 {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.list-pager {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.list-pager button {
|
||||
background: #15161c;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--gold);
|
||||
cursor: pointer;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
min-height: 34px;
|
||||
outline: 2px solid #41404a;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.list-pager button:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.list-pager span {
|
||||
color: var(--muted);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.crafting-list > button {
|
||||
align-items: center;
|
||||
background: var(--panel-light);
|
||||
@@ -2698,6 +2973,10 @@ h2 {
|
||||
--rarity-color: #b584e3;
|
||||
}
|
||||
|
||||
.rarity-legendary {
|
||||
--rarity-color: #f2a13a;
|
||||
}
|
||||
|
||||
.rarity-common {
|
||||
--rarity-color: #a8a3ad;
|
||||
}
|
||||
@@ -3226,7 +3505,7 @@ h2 {
|
||||
.party-grid {
|
||||
display: grid;
|
||||
gap: 11px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: 17px;
|
||||
}
|
||||
|
||||
@@ -3243,11 +3522,12 @@ h2 {
|
||||
}
|
||||
|
||||
.party-member:first-child {
|
||||
grid-column: 1 / -1;
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.raid-party-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.raid-party-grid .party-member:first-child {
|
||||
@@ -3379,6 +3659,39 @@ h2 {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.member-health .health-text {
|
||||
color: var(--ink);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
font-style: normal;
|
||||
left: 50%;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
text-shadow: 1px 1px #08090c;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: nowrap;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.raid-party-grid .party-member {
|
||||
min-height: 66px;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.raid-party-grid .member-header {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.raid-party-grid .member-header strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.raid-party-grid .member-header small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.member-effects {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -3724,6 +4037,8 @@ h2 {
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -3733,8 +4048,10 @@ h2 {
|
||||
background: var(--panel);
|
||||
border: 3px solid #0b0c0f;
|
||||
box-shadow: 8px 8px 0 #050507;
|
||||
max-height: calc(100dvh - 32px);
|
||||
max-width: 520px;
|
||||
outline: 2px solid var(--gold);
|
||||
overflow-y: auto;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -3915,18 +4232,25 @@ h2 {
|
||||
}
|
||||
|
||||
.pvp-match-screen {
|
||||
gap: 16px;
|
||||
gap: 0;
|
||||
height: calc(100dvh - 24px);
|
||||
margin-top: 0;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pvp-board {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.8fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.68fr) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pvp-side,
|
||||
.pvp-middle-panel {
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pvp-vertical-spell-bar,
|
||||
@@ -3935,7 +4259,8 @@ h2 {
|
||||
}
|
||||
|
||||
.pvp-vertical-spell-bar .spell {
|
||||
min-height: 86px;
|
||||
min-height: 58px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.pvp-screen-tools {
|
||||
@@ -3950,9 +4275,9 @@ h2 {
|
||||
|
||||
.pvp-resource-wrap {
|
||||
color: #82bfff;
|
||||
min-width: 220px;
|
||||
min-width: 150px;
|
||||
text-align: right;
|
||||
width: min(240px, 100%);
|
||||
width: min(170px, 100%);
|
||||
}
|
||||
|
||||
.pvp-resource-wrap > span {
|
||||
@@ -3966,16 +4291,93 @@ h2 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pvp-side .party-grid {
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pvp-side .pvp-party-grid.raid {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.pvp-side .pvp-party-grid.raid .party-member {
|
||||
min-height: 62px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.pvp-side .pvp-party-grid.raid .member-header strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.pvp-side .pvp-party-grid.raid .member-header small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pvp-side .party-member {
|
||||
min-height: 76px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pvp-side .party-member:first-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.pvp-side .member-header {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.pvp-side .member-header strong {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.pvp-side .member-header small {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pvp-side .bar {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.pvp-side .member-effects {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pvp-side .member-effects span {
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.pvp-side .encounter-header .eyebrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pvp-enemy-race {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pvp-middle-panel .encounter-header h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.pvp-middle-panel .encounter-header small,
|
||||
.pvp-enemy-race small {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pvp-middle-panel .roguelike-upgrade-list,
|
||||
.pvp-side .roguelike-upgrade-list {
|
||||
font-size: 12px;
|
||||
line-height: 1.1;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pvp-choice-columns {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 16px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.pvp-choice-columns > div > strong {
|
||||
@@ -3989,8 +4391,8 @@ h2 {
|
||||
|
||||
.pvp-choice-columns .upgrade-choice-grid button {
|
||||
background: #252833;
|
||||
min-height: 120px;
|
||||
padding: 14px;
|
||||
min-height: 70px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pvp-leaderboard-row {
|
||||
@@ -3999,20 +4401,31 @@ h2 {
|
||||
|
||||
.pvp-upgrade-dialog {
|
||||
max-width: 1120px !important;
|
||||
padding: 12px !important;
|
||||
text-align: left !important;
|
||||
width: min(1120px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog > p:not(.eyebrow) {
|
||||
font-size: 18px !important;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog .pvp-choice-columns {
|
||||
gap: 10px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog .upgrade-choice-grid strong {
|
||||
color: #ffe8a5;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
font-size: 9px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog .upgrade-choice-grid small {
|
||||
color: #d3d9e6;
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
font-size: 12px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog .upgrade-choice-grid button.selected-upgrade {
|
||||
@@ -4057,9 +4470,191 @@ h2 {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) and (min-height: 900px) {
|
||||
.game-shell {
|
||||
width: min(1220px, calc(100% - 20px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) and (max-height: 1120px) {
|
||||
.settings-screen,
|
||||
.equipment-screen,
|
||||
.talent-screen,
|
||||
.customize-screen {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.settings-heading {
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
|
||||
.settings-heading > p,
|
||||
.controller-preferences p:not(.eyebrow),
|
||||
.dual-screen-settings p:not(.eyebrow) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.binding-tabs {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.binding-tabs button {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.binding-list {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.binding-list button {
|
||||
min-height: 39px;
|
||||
padding: 6px 9px;
|
||||
}
|
||||
|
||||
.binding-list button > span {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.controller-preferences,
|
||||
.dual-screen-settings {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.controller-icon-options {
|
||||
grid-template-columns: minmax(120px, 1fr) repeat(3, minmax(118px, auto));
|
||||
}
|
||||
|
||||
.gear-summary,
|
||||
.talent-toolbar {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.equipment-tabs,
|
||||
.talent-page-tabs {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.equipment-tab {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.item-comparison {
|
||||
grid-template-columns: 1fr auto 1fr minmax(132px, 0.45fr);
|
||||
margin-top: 10px;
|
||||
min-height: 122px;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.item-detail {
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.item-detail > p:not(.eyebrow),
|
||||
.item-detail ul {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.equipment-layout {
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.equipped-panel,
|
||||
.inventory-panel,
|
||||
.crafting-panel,
|
||||
.set-bonus-panel {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.equipment-slots,
|
||||
.inventory-list,
|
||||
.crafting-list {
|
||||
gap: 6px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.equipment-slots > button,
|
||||
.inventory-list > button,
|
||||
.crafting-list > button {
|
||||
min-height: 46px;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
.equipment-slots > button > span,
|
||||
.inventory-list > button > span,
|
||||
.crafting-list > button > span,
|
||||
.item-title > span {
|
||||
height: 31px;
|
||||
}
|
||||
|
||||
.inventory-list,
|
||||
.crafting-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.crafting-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.crafting-filter-bar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.crafting-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.crafting-list {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.crafting-detail {
|
||||
align-content: start;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.talent-tree {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.talent-tier {
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.talent-node {
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.talent-node > p {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.rank-pips {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.talent-footer {
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 720px) {
|
||||
.game-shell {
|
||||
margin: 6px auto;
|
||||
padding: 6px 0;
|
||||
width: min(1180px, calc(100% - 20px));
|
||||
}
|
||||
|
||||
|
||||
+84
-8
@@ -19,7 +19,12 @@ import {
|
||||
type AuthSession,
|
||||
type CharacterProfile,
|
||||
} from './profile'
|
||||
import { getGameMode, type GameMode } from './gameRepository'
|
||||
import {
|
||||
getCloudSyncStatus,
|
||||
getGameMode,
|
||||
syncCloudSave,
|
||||
type GameMode,
|
||||
} from './gameRepository'
|
||||
import { focusFirstControl } from './input.tsx'
|
||||
|
||||
type Screen =
|
||||
@@ -40,8 +45,8 @@ const MENU_ITEMS: Array<{
|
||||
glyph: string
|
||||
description: string
|
||||
}> = [
|
||||
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a five-player party through dangerous encounters.' },
|
||||
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide a ten-player party through three-phase challenges.' },
|
||||
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a six-player party through dangerous encounters.' },
|
||||
{ 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: '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.' },
|
||||
@@ -49,6 +54,7 @@ const MENU_ITEMS: Array<{
|
||||
]
|
||||
|
||||
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
||||
const SHOW_LEADERBOARDS = false
|
||||
|
||||
function activityInitials(name: string) {
|
||||
return name
|
||||
@@ -88,6 +94,8 @@ function App() {
|
||||
const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence')
|
||||
const [showLeaderboard, setShowLeaderboard] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [syncingCloud, setSyncingCloud] = useState(false)
|
||||
const [syncMessage, setSyncMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadAuthSession()
|
||||
@@ -105,6 +113,17 @@ function App() {
|
||||
.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(() => {
|
||||
if (screen === 'combat') return
|
||||
window.requestAnimationFrame(() => {
|
||||
@@ -112,6 +131,13 @@ function App() {
|
||||
})
|
||||
}, [screen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked || !account || !profile || screen === 'combat') return
|
||||
window.requestAnimationFrame(() => {
|
||||
focusFirstControl()
|
||||
})
|
||||
}, [account, authChecked, profile, screen])
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
|
||||
}, [selectedDifficultyId])
|
||||
@@ -129,6 +155,9 @@ function App() {
|
||||
setScreen('menu')
|
||||
setError('')
|
||||
setServerMessage('')
|
||||
window.requestAnimationFrame(() => {
|
||||
focusFirstControl()
|
||||
})
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
@@ -138,11 +167,27 @@ function App() {
|
||||
setProfile(null)
|
||||
setGameMode(getGameMode())
|
||||
setScreen('menu')
|
||||
setSyncMessage('')
|
||||
} catch (reason) {
|
||||
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) {
|
||||
return (
|
||||
<main className="game-shell">
|
||||
@@ -252,7 +297,8 @@ function App() {
|
||||
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
|
||||
{ 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]
|
||||
.filter((encounter) => encounter.isBoss)
|
||||
.sort((a, b) => lootSort === 'boss'
|
||||
@@ -285,6 +331,28 @@ function App() {
|
||||
{screen === 'menu' && (
|
||||
<section className="menu-screen">
|
||||
<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) => (
|
||||
<button
|
||||
className="menu-card"
|
||||
@@ -457,6 +525,7 @@ function App() {
|
||||
Start Match
|
||||
</button>
|
||||
</div>
|
||||
{SHOW_LEADERBOARDS && (
|
||||
<div className="leaderboard-section">
|
||||
<div className="equipment-heading toggle-heading">
|
||||
<div>
|
||||
@@ -488,6 +557,7 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
@@ -621,7 +691,7 @@ function App() {
|
||||
</label>
|
||||
</div>
|
||||
<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
|
||||
? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
|
||||
: ''}
|
||||
@@ -641,7 +711,7 @@ function App() {
|
||||
)}
|
||||
<div>
|
||||
<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 className="loot-items">
|
||||
@@ -663,6 +733,7 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{SHOW_LEADERBOARDS && (
|
||||
<div className="leaderboard-section">
|
||||
<div className="equipment-heading toggle-heading">
|
||||
<div>
|
||||
@@ -682,7 +753,9 @@ function App() {
|
||||
<p className="section-note">
|
||||
{gameMode === 'offline'
|
||||
? 'Offline runs are not submitted'
|
||||
: 'Lowest resource spent ranks first'}
|
||||
: canShowCloudSync
|
||||
? 'Manual save sync updates your cloud profile.'
|
||||
: 'Lowest resource spent ranks first'}
|
||||
</p>
|
||||
<div className="leaderboard-tabs">
|
||||
{([
|
||||
@@ -730,13 +803,16 @@ function App() {
|
||||
<div className="leaderboard-empty">
|
||||
{gameMode === 'offline'
|
||||
? 'Connect with an online character to compete in rankings.'
|
||||
: 'Complete this difficulty to claim the first ranking.'}
|
||||
: canShowCloudSync
|
||||
? 'No leaderboard entries yet.'
|
||||
: 'Complete this difficulty to claim the first ranking.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ function ItemsTab({ data, setData, setSaving, saving }: {
|
||||
</label>
|
||||
<label>Rarity
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -156,7 +156,7 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
|
||||
<h2>Play Offline</h2>
|
||||
<p>
|
||||
No account or connection required. Offline progress stays on
|
||||
this device and is excluded from online leaderboards.
|
||||
this device.
|
||||
</p>
|
||||
</div>
|
||||
{offlineCharacterExists && (
|
||||
|
||||
@@ -241,7 +241,7 @@ function makeRoguelikeSegment(
|
||||
encounter.maxHealth
|
||||
+ encounter.damage * 18
|
||||
+ encounter.tankDamage * 10
|
||||
+ encounter.partyDamage * 12
|
||||
+ encounter.partyDamage * 18
|
||||
)
|
||||
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
|
||||
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
||||
@@ -331,7 +331,7 @@ export function CombatScreen({
|
||||
)
|
||||
const maxResource = gameClass.maxResource + (isRoguelike ? 0 : profile.gearStats.maxResourceBonus)
|
||||
const partyTemplate = useMemo(
|
||||
() => (dungeon.partySize === 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
||||
() => (dungeon.partySize >= 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
||||
...member,
|
||||
name: member.id === 'mira' ? profile.character.name : member.name,
|
||||
})),
|
||||
@@ -346,10 +346,10 @@ export function CombatScreen({
|
||||
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 [, setElapsedTicks] = useState(0)
|
||||
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
|
||||
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[]>([
|
||||
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
|
||||
])
|
||||
@@ -373,6 +373,8 @@ export function CombatScreen({
|
||||
const nextFloatingTextId = useRef(1)
|
||||
const partyRef = useRef(partyTemplate)
|
||||
const enemyHealthRef = useRef(encounters[initialEncounterIndex].maxHealth)
|
||||
const elapsedTicksRef = useRef(0)
|
||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||
const encounter = encounters[encounterIndex]
|
||||
const currentPart = getCurrentPart(encounterIndex)
|
||||
const firstEncounterIndex = (startPart - 1) * 3
|
||||
@@ -414,6 +416,11 @@ export function CombatScreen({
|
||||
})
|
||||
}, [paused])
|
||||
|
||||
const setSelectedTargetId = useCallback((id: string) => {
|
||||
selectedIdRef.current = id
|
||||
setSelectedId(id)
|
||||
}, [])
|
||||
|
||||
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
||||
const entry = { id: nextLogId.current++, text, tone }
|
||||
setLog((current) => [entry, ...current].slice(0, 60))
|
||||
@@ -466,11 +473,12 @@ export function CombatScreen({
|
||||
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
|
||||
setRoguelikeStage(1)
|
||||
setParty(freshParty)
|
||||
setSelectedId(partyTemplate[0].id)
|
||||
setSelectedTargetId(partyTemplate[0].id)
|
||||
setResource(maxResource)
|
||||
setEncounterIndex(initialEncounterIndex)
|
||||
setEnemyHealth(nextEncounters[initialEncounterIndex].maxHealth)
|
||||
setCooldowns({})
|
||||
elapsedTicksRef.current = 0
|
||||
setElapsedTicks(0)
|
||||
setStatus('playing')
|
||||
setPaused(false)
|
||||
@@ -492,7 +500,7 @@ export function CombatScreen({
|
||||
runStartedAtRef.current = Date.now()
|
||||
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
||||
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, setSelectedTargetId, startPart, staticEncounters])
|
||||
|
||||
const castSpell = useCallback(
|
||||
(spell: Spell) => {
|
||||
@@ -500,26 +508,27 @@ export function CombatScreen({
|
||||
if (status !== 'playing' || cooldowns[spell.id] > 0 || resource < effectiveCost) return
|
||||
const healer = partyRef.current.find((member) => member.id === 'mira')
|
||||
if (!healer || healer.health <= 0) return
|
||||
const selected = partyRef.current.find((member) => member.id === selectedId)
|
||||
const targetId = selectedIdRef.current
|
||||
const selected = partyRef.current.find((member) => member.id === targetId)
|
||||
if (!selected || selected.health <= 0) return
|
||||
const extraTarget = (blockedIds: string[]) => partyRef.current
|
||||
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
||||
.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 shieldTargets = new Set<string>()
|
||||
if (spell.kind === 'hot') hotTargets.add(selectedId)
|
||||
if (spell.kind === 'shield') shieldTargets.add(selectedId)
|
||||
if (spell.kind === 'hot') hotTargets.add(targetId)
|
||||
if (spell.kind === 'shield') shieldTargets.add(targetId)
|
||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
|
||||
const extra = extraTarget([selectedId])
|
||||
const extra = extraTarget([targetId])
|
||||
if (extra) directTargets.add(extra.id)
|
||||
}
|
||||
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) {
|
||||
const extra = extraTarget([selectedId])
|
||||
const extra = extraTarget([targetId])
|
||||
if (extra) hotTargets.add(extra.id)
|
||||
}
|
||||
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) {
|
||||
@@ -597,7 +606,7 @@ export function CombatScreen({
|
||||
setParty(nextParty)
|
||||
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, cooldowns, freeCastReady, resource, roguelikeUpgrades, status],
|
||||
)
|
||||
|
||||
const finishRun = useCallback(
|
||||
@@ -662,18 +671,18 @@ export function CombatScreen({
|
||||
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
||||
const living = partyRef.current.filter((member) => member.health > 0)
|
||||
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
|
||||
? 0
|
||||
: (currentIndex + direction + living.length) % living.length
|
||||
setSelectedId(living[nextIndex].id)
|
||||
}, [selectedId])
|
||||
setSelectedTargetId(living[nextIndex].id)
|
||||
}, [setSelectedTargetId])
|
||||
|
||||
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
||||
const columns = dungeon.partySize === 10 ? 5 : 3
|
||||
const currentIndex = partyRef.current.findIndex((member) => member.id === selectedId)
|
||||
const columns = dungeon.partySize >= 10 ? 6 : 3
|
||||
const currentIndex = partyRef.current.findIndex((member) => member.id === selectedIdRef.current)
|
||||
if (currentIndex < 0) {
|
||||
setSelectedId(partyRef.current[0].id)
|
||||
setSelectedTargetId(partyRef.current[0].id)
|
||||
return
|
||||
}
|
||||
const currentRow = Math.floor(currentIndex / columns)
|
||||
@@ -707,14 +716,14 @@ export function CombatScreen({
|
||||
: Math.abs(b.column - currentColumn)
|
||||
return aPrimary - bPrimary || aSecondary - bSecondary
|
||||
})
|
||||
if (candidates[0]) setSelectedId(candidates[0].member.id)
|
||||
}, [dungeon.partySize, selectedId])
|
||||
if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
|
||||
}, [dungeon.partySize, setSelectedTargetId])
|
||||
|
||||
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]
|
||||
if (member) setSelectedId(member.id)
|
||||
}, [dungeon.partySize, targetGroup])
|
||||
if (member) setSelectedTargetId(member.id)
|
||||
}, [dungeon.partySize, setSelectedTargetId, targetGroup])
|
||||
|
||||
const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
|
||||
if (!roguelikeMode) return
|
||||
@@ -748,6 +757,7 @@ export function CombatScreen({
|
||||
setParty(recoveredParty)
|
||||
setEncounterIndex((current) => current + 1)
|
||||
setEnemyHealth(nextEncounter.maxHealth)
|
||||
elapsedTicksRef.current = 0
|
||||
setElapsedTicks(0)
|
||||
setCooldowns({})
|
||||
setResource((value) => clamp(value + Math.round(maxResource * 0.25), 0, maxResource))
|
||||
@@ -771,12 +781,13 @@ export function CombatScreen({
|
||||
return
|
||||
}
|
||||
if (action === 'toggleTargetGroup') {
|
||||
if (dungeon.partySize !== 10) return
|
||||
if (dungeon.partySize <= 6) return
|
||||
setTargetGroup((current) => {
|
||||
const next = current === 0 ? 1 : 0
|
||||
const selectedIndex = partyRef.current.findIndex((member) => member.id === selectedId)
|
||||
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 5) + next * 5]
|
||||
if (nextMember) setSelectedId(nextMember.id)
|
||||
const groupCount = Math.max(1, Math.ceil(partyRef.current.length / 6))
|
||||
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
||||
const selectedIndex = partyRef.current.findIndex((member) => member.id === selectedIdRef.current)
|
||||
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
||||
if (nextMember) setSelectedTargetId(nextMember.id)
|
||||
return next
|
||||
})
|
||||
return
|
||||
@@ -798,7 +809,9 @@ export function CombatScreen({
|
||||
useEffect(() => {
|
||||
if (status !== 'playing' || paused) return
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedTicks((value) => value + 1)
|
||||
const nextElapsedTicks = elapsedTicksRef.current + 1
|
||||
elapsedTicksRef.current = nextElapsedTicks
|
||||
setElapsedTicks(nextElapsedTicks)
|
||||
setResource((value) => clamp(value + 2.4, 0, maxResource))
|
||||
setCooldowns((current) =>
|
||||
Object.fromEntries(
|
||||
@@ -820,19 +833,19 @@ export function CombatScreen({
|
||||
const primaryTarget = living[Math.floor(Math.random() * living.length)]
|
||||
const mechanics = (encounter as RoguelikeEncounter).roguelikeMechanics ?? []
|
||||
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'))
|
||||
const appliesDebuff = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 11 === 0
|
||||
const appliesDebuff = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 11 === 0
|
||||
&& (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')
|
||||
const appliesHealingReduction = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0
|
||||
const appliesHealingReduction = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 9 === 0
|
||||
&& 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')
|
||||
const resourceDrain = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 10 === 0
|
||||
const resourceDrain = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 10 === 0
|
||||
&& 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')
|
||||
if (bossPulse) addLog(`${encounter.enemyName} unleashes party-wide damage.`, 'danger')
|
||||
if (appliesDebuff) addLog(`${primaryTarget.name} is afflicted by Searing Mark.`, 'danger')
|
||||
@@ -957,6 +970,7 @@ export function CombatScreen({
|
||||
setParty(recoveredParty)
|
||||
setEncounterIndex((value) => value + 1)
|
||||
setEnemyHealth(nextEncounter.maxHealth)
|
||||
elapsedTicksRef.current = 0
|
||||
setElapsedTicks(0)
|
||||
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}, TICK_MS)
|
||||
@@ -965,7 +979,6 @@ export function CombatScreen({
|
||||
addLog,
|
||||
addFloatingHeal,
|
||||
difficulty.damageMultiplier,
|
||||
elapsedTicks,
|
||||
encounter,
|
||||
encounterIndex,
|
||||
encounters,
|
||||
@@ -1123,12 +1136,12 @@ export function CombatScreen({
|
||||
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></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) => (
|
||||
<button
|
||||
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
||||
key={member.id}
|
||||
onClick={() => setSelectedId(member.id)}
|
||||
onClick={() => setSelectedTargetId(member.id)}
|
||||
aria-pressed={selectedId === member.id}
|
||||
type="button"
|
||||
>
|
||||
@@ -1146,6 +1159,7 @@ export function CombatScreen({
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / 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 className="floating-combat-texts" aria-hidden="true">
|
||||
{floatingTexts
|
||||
@@ -1212,7 +1226,7 @@ export function CombatScreen({
|
||||
{dualScreenEnabled && (
|
||||
<DualScreenTopCombat
|
||||
state={dualScreenState}
|
||||
onSelectTarget={setSelectedId}
|
||||
onSelectTarget={setSelectedTargetId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1314,7 +1328,7 @@ export function CombatScreen({
|
||||
<div className="bonus-item-detail">
|
||||
<span>{reward.bonusItem.glyph}</span>
|
||||
<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>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1395,6 +1409,7 @@ export function CombatScreen({
|
||||
setParty(recoveredParty)
|
||||
setEncounterIndex(nextIndex)
|
||||
setEnemyHealth(nextEncounter.maxHealth)
|
||||
elapsedTicksRef.current = 0
|
||||
setElapsedTicks(0)
|
||||
setStatus('playing')
|
||||
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
craftItem,
|
||||
equipItem,
|
||||
loadProfile,
|
||||
upgradeItem,
|
||||
type CharacterProfile,
|
||||
type EquipmentSlot,
|
||||
type Item,
|
||||
@@ -22,6 +23,9 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
||||
component: 'Component',
|
||||
}
|
||||
|
||||
const EQUIPMENT_LIST_PAGE_SIZE = 3
|
||||
const CRAFTING_LIST_PAGE_SIZE = 6
|
||||
|
||||
type Props = {
|
||||
profile: CharacterProfile
|
||||
onBack?: () => void
|
||||
@@ -43,8 +47,11 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
const [equipping, setEquipping] = useState(false)
|
||||
const [breakingDown, setBreakingDown] = useState(false)
|
||||
const [crafting, setCrafting] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
||||
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
|
||||
const [inventoryPage, setInventoryPage] = useState(0)
|
||||
const [recipePage, setRecipePage] = useState(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const scrollRef = useRef<number>(0)
|
||||
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
|
||||
@@ -54,6 +61,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
firstRecipe?.id ?? null,
|
||||
)
|
||||
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
||||
const selectedItemRecipe = selectedItem
|
||||
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||
: undefined
|
||||
const upgradeRecipe = selectedItem && selectedItemRecipe
|
||||
? profile.craftingRecipes.find((recipe) =>
|
||||
recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId
|
||||
&& recipe.item.slot === selectedItem.slot
|
||||
&& recipe.item.itemLevel === selectedItem.itemLevel + 5,
|
||||
)
|
||||
: undefined
|
||||
const equippedBySlot = useMemo(
|
||||
() => new Map(
|
||||
profile.inventory
|
||||
@@ -75,6 +92,14 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
(total, item) => total + item.quantity,
|
||||
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 [levelFilter, setLevelFilter] = useState<number | null>(null)
|
||||
@@ -92,11 +117,27 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
},
|
||||
[profile.craftingRecipes, slotFilter, levelFilter],
|
||||
)
|
||||
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(() => {
|
||||
window.scrollTo(0, scrollRef.current)
|
||||
}, [profile])
|
||||
|
||||
useEffect(() => {
|
||||
setInventoryPage((current) => Math.min(current, inventoryPageCount - 1))
|
||||
}, [inventoryPageCount])
|
||||
|
||||
useEffect(() => {
|
||||
setRecipePage((current) => Math.min(current, recipePageCount - 1))
|
||||
}, [recipePageCount])
|
||||
|
||||
useEffect(() => {
|
||||
if (equipmentTab === 'crafting') {
|
||||
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
|
||||
@@ -160,6 +201,23 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && (
|
||||
@@ -230,16 +288,26 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={selectedItem.equipped || equipping || breakingDown}
|
||||
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}
|
||||
disabled={equipping || breakingDown || upgrading}
|
||||
onClick={breakdownSelected}
|
||||
type="button"
|
||||
>
|
||||
@@ -270,6 +338,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
key={slot}
|
||||
onClick={() => {
|
||||
setSelectedSlot(slot)
|
||||
setInventoryPage(0)
|
||||
const firstSlotItem = profile.inventory.find(
|
||||
(candidate) => candidate.slot === slot,
|
||||
)
|
||||
@@ -302,14 +371,17 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
{selectedSlot && (
|
||||
<button
|
||||
className="inventory-filter-clear"
|
||||
onClick={() => setSelectedSlot(null)}
|
||||
onClick={() => {
|
||||
setSelectedSlot(null)
|
||||
setInventoryPage(0)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Show All Items
|
||||
</button>
|
||||
)}
|
||||
<div className="inventory-list">
|
||||
{visibleInventory.map((item) => (
|
||||
{inventoryPageItems.map((item) => (
|
||||
<button
|
||||
className={`${selectedItemId === item.id ? 'selected' : ''} rarity-${item.rarity}`}
|
||||
key={item.id}
|
||||
@@ -333,6 +405,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
@@ -347,7 +428,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
<select
|
||||
className="filter-select"
|
||||
value={slotFilter}
|
||||
onChange={(e) => setSlotFilter(e.target.value as EquipmentSlot | 'all')}
|
||||
onChange={(e) => {
|
||||
setSlotFilter(e.target.value as EquipmentSlot | 'all')
|
||||
setRecipePage(0)
|
||||
}}
|
||||
>
|
||||
<option value="all">All Slots</option>
|
||||
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
|
||||
@@ -357,7 +441,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
<select
|
||||
className="filter-select"
|
||||
value={levelFilter ?? ''}
|
||||
onChange={(e) => setLevelFilter(e.target.value === '' ? null : Number(e.target.value))}
|
||||
onChange={(e) => {
|
||||
setLevelFilter(e.target.value === '' ? null : Number(e.target.value))
|
||||
setRecipePage(0)
|
||||
}}
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
{availableLevels.map((level) => (
|
||||
@@ -371,7 +458,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
{filteredRecipes.length > 0 && (
|
||||
<div className="crafting-layout">
|
||||
<div className="crafting-list">
|
||||
{filteredRecipes.map((recipe) => (
|
||||
{recipePageItems.map((recipe) => (
|
||||
<button
|
||||
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
|
||||
key={recipe.id}
|
||||
@@ -389,6 +476,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i>
|
||||
</button>
|
||||
))}
|
||||
{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>
|
||||
{selectedRecipe && (
|
||||
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
|
||||
@@ -466,6 +562,28 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
)
|
||||
}
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<div className="gear-stat">
|
||||
|
||||
@@ -4,10 +4,18 @@ import { completeRoguelike, type DungeonReward } from '../profile'
|
||||
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
|
||||
import type { GameMode } from '../gameRepository'
|
||||
import { ControllerBindingLabel } from './ControllerIcons'
|
||||
import { useGameAction, useInput, type InputAction } from '../input'
|
||||
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
|
||||
import {
|
||||
DualScreenTopCombat,
|
||||
useDualScreen,
|
||||
useDualScreenPublisher,
|
||||
type DualScreenCombatState,
|
||||
} from '../dualScreen'
|
||||
import {
|
||||
loadPvpRoguelikeCheckpoint,
|
||||
randomCpuDifficulty,
|
||||
recordCpuPvpLeaderboard,
|
||||
recordPvpRoguelikeCheckpoint,
|
||||
type CpuDifficulty,
|
||||
type PvpContentType,
|
||||
} from '../pvpRoguelike'
|
||||
@@ -23,6 +31,7 @@ type BossMechanic =
|
||||
|
||||
type PvpEncounter = DungeonEncounter & {
|
||||
bossMechanics?: BossMechanic[]
|
||||
sourceEncounterId?: number
|
||||
}
|
||||
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
||||
@@ -238,7 +247,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
|
||||
encounter.maxHealth
|
||||
+ encounter.damage * 18
|
||||
+ encounter.tankDamage * 10
|
||||
+ encounter.partyDamage * 12
|
||||
+ encounter.partyDamage * 18
|
||||
)
|
||||
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
|
||||
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
||||
@@ -255,6 +264,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
|
||||
const isBoss = index === 2
|
||||
return {
|
||||
...encounter,
|
||||
sourceEncounterId: encounter.id,
|
||||
id: 910000 + stage * 10 + index,
|
||||
sequence: (stage - 1) * 3 + index + 1,
|
||||
isBoss,
|
||||
@@ -366,7 +376,7 @@ export function PvPRoguelikeScreen({
|
||||
.filter((spell) => spell.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
||||
const [abilityLabelMode, setAbilityLabelMode] = useState<AbilityLabelMode>('ability')
|
||||
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
|
||||
const selfBuffChoicesCatalog = useMemo(
|
||||
() => buildSelfBuffChoices(starterSpells, abilityLabelMode),
|
||||
[abilityLabelMode, starterSpells],
|
||||
@@ -375,6 +385,10 @@ export function PvPRoguelikeScreen({
|
||||
() => buildOpponentDebuffChoices(starterSpells, abilityLabelMode),
|
||||
[abilityLabelMode, starterSpells],
|
||||
)
|
||||
const [checkpointStage, setCheckpointStage] = useState(() =>
|
||||
loadPvpRoguelikeCheckpoint(profile.character.id, contentType),
|
||||
)
|
||||
const [startStage, setStartStage] = useState(checkpointStage)
|
||||
const maxResource = gameClass.maxResource
|
||||
const partyTemplate = useMemo(
|
||||
() => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
||||
@@ -391,8 +405,8 @@ export function PvPRoguelikeScreen({
|
||||
[contentType],
|
||||
)
|
||||
const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
|
||||
const [stage, setStage] = useState(1)
|
||||
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, 1, contentType))
|
||||
const [stage, setStage] = useState(startStage)
|
||||
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
|
||||
const [encounterIndex, setEncounterIndex] = useState(0)
|
||||
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
|
||||
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
||||
@@ -410,10 +424,14 @@ export function PvPRoguelikeScreen({
|
||||
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
||||
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
||||
const [encountersCleared, setEncountersCleared] = useState(0)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||
const nextLogId = useRef(2)
|
||||
const nextFloatingTextId = useRef(1)
|
||||
const recordedRunRef = useRef(false)
|
||||
const rewardClaimedRef = useRef(false)
|
||||
const bossRewardClaimedRef = useRef(new Set<number>())
|
||||
const cpuDefeatedRef = useRef(false)
|
||||
const playerClearedEncounterRef = useRef(-1)
|
||||
const playerRef = useRef(playerSide)
|
||||
const cpuRef = useRef(cpuSide)
|
||||
@@ -431,11 +449,16 @@ export function PvPRoguelikeScreen({
|
||||
const cpuDone = cpuSide.enemyHealth <= 0
|
||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
|
||||
const partyColumns = contentType === 'raid' ? 6 : 3
|
||||
const {
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
lastDevice,
|
||||
} = useInput()
|
||||
const {
|
||||
enabled: dualScreenEnabled,
|
||||
} = useDualScreen()
|
||||
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
||||
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
|
||||
}, [])
|
||||
@@ -449,31 +472,50 @@ export function PvPRoguelikeScreen({
|
||||
}, 900)
|
||||
}, [])
|
||||
|
||||
const finishRoguelikeRun = useCallback((cleared: number) => {
|
||||
if (rewardClaimedRef.current) return
|
||||
rewardClaimedRef.current = true
|
||||
const bossesCleared = Math.floor(cleared / 3)
|
||||
useEffect(() => {
|
||||
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||
setCheckpointStage(loadedCheckpoint)
|
||||
setStartStage(loadedCheckpoint)
|
||||
}, [contentType, profile.character.id])
|
||||
|
||||
const awardBossReward = useCallback((encounterIndexValue: number) => {
|
||||
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
||||
bossRewardClaimedRef.current.add(encounterIndexValue)
|
||||
const rewardEncounter = encounters[encounterIndexValue]
|
||||
completeRoguelike(
|
||||
rewardDungeon.id,
|
||||
rewardDifficulty.id,
|
||||
cleared,
|
||||
0,
|
||||
0,
|
||||
Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
|
||||
{
|
||||
bossesCleared,
|
||||
bossesCleared: 1,
|
||||
experienceMode: 'pvp-boss-quarter-level',
|
||||
lootSourceEncounterId: rewardEncounter?.sourceEncounterId,
|
||||
roguelikeStage: stage,
|
||||
},
|
||||
)
|
||||
.then((result) => {
|
||||
setReward(result)
|
||||
onProfileUpdated(result.profile)
|
||||
if (result.bonusItem) {
|
||||
addLog(
|
||||
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
|
||||
'loot',
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
setRewardError(
|
||||
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(() => {
|
||||
setPlayerBuffChoices((current) => current
|
||||
@@ -491,7 +533,7 @@ export function PvPRoguelikeScreen({
|
||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||
|
||||
useEffect(() => {
|
||||
const firstSegment = buildEncounterSegment(encounterPool, 1, contentType)
|
||||
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType)
|
||||
const firstEncounter = firstSegment[0]
|
||||
const basePlayer = starterSide(partyTemplate, maxResource)
|
||||
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
|
||||
@@ -501,9 +543,10 @@ export function PvPRoguelikeScreen({
|
||||
cpuRef.current = baseCpu
|
||||
nextLogId.current = 2
|
||||
playerClearedEncounterRef.current = -1
|
||||
bossRewardClaimedRef.current = new Set()
|
||||
setEncounters(firstSegment)
|
||||
setEncounterIndex(0)
|
||||
setStage(1)
|
||||
setStage(startStage)
|
||||
setElapsedTicks(0)
|
||||
setStatus('queueing')
|
||||
setPlayerSide(basePlayer)
|
||||
@@ -514,6 +557,8 @@ export function PvPRoguelikeScreen({
|
||||
setSelectedBuff(null)
|
||||
setSelectedDebuff(null)
|
||||
setEncountersCleared(0)
|
||||
setPaused(false)
|
||||
setTargetGroup(0)
|
||||
setReward(null)
|
||||
setRewardError('')
|
||||
setShowEndLog(false)
|
||||
@@ -521,28 +566,29 @@ export function PvPRoguelikeScreen({
|
||||
setCpuDifficulty(null)
|
||||
recordedRunRef.current = false
|
||||
rewardClaimedRef.current = false
|
||||
cpuDefeatedRef.current = false
|
||||
if (gameMode === 'offline') {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters immediately.`)
|
||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`)
|
||||
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 ${startStage}.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
setStatus('playing')
|
||||
addLog(`Round 1 begins against CPU ${randomCpu}.`, 'system')
|
||||
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system')
|
||||
}, 500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
setQueueMessage('Searching queue. No player found yet.')
|
||||
setLog([{ id: 1, text: 'Searching queue. No player found yet.', tone: 'system' }])
|
||||
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`)
|
||||
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setCpuDifficulty(randomCpu)
|
||||
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
||||
setStatus('playing')
|
||||
addLog(`No queued player found. CPU ${randomCpu} steps in.`, 'system')
|
||||
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system')
|
||||
}, 1400)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate])
|
||||
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage])
|
||||
|
||||
const applySpell = useCallback((
|
||||
current: SideState,
|
||||
@@ -659,10 +705,45 @@ export function PvPRoguelikeScreen({
|
||||
setSelectedId(living[nextIndex].id)
|
||||
}, [selectedId])
|
||||
|
||||
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
||||
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
|
||||
if (currentIndex < 0) {
|
||||
const firstLiving = playerRef.current.party.find((member) => member.health > 0)
|
||||
if (firstLiving) setSelectedId(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]) setSelectedId(candidates[0].member.id)
|
||||
}, [partyColumns, selectedId])
|
||||
|
||||
const selectDirectTarget = useCallback((slot: number) => {
|
||||
const member = playerRef.current.party[slot]
|
||||
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
|
||||
const member = playerRef.current.party[index]
|
||||
if (member?.health > 0) setSelectedId(member.id)
|
||||
}, [])
|
||||
}, [contentType, targetGroup])
|
||||
|
||||
const cpuTakeTurn = useCallback(() => {
|
||||
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
|
||||
@@ -774,7 +855,7 @@ export function PvPRoguelikeScreen({
|
||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'playing' || !encounter) return
|
||||
if (status !== 'playing' || paused || !encounter) return
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedTicks((value) => value + 1)
|
||||
cpuTakeTurn()
|
||||
@@ -783,6 +864,18 @@ export function PvPRoguelikeScreen({
|
||||
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
|
||||
playerClearedEncounterRef.current = encounterIndex
|
||||
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
|
||||
cpuRef.current = nextCpu
|
||||
@@ -791,28 +884,23 @@ export function PvPRoguelikeScreen({
|
||||
|
||||
const nextPlayerAlive = nextPlayer.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) {
|
||||
finishRoguelikeRun(clearedCount)
|
||||
finishRoguelikeRun()
|
||||
setStatus('lost')
|
||||
addLog('Your party fell first.', 'danger')
|
||||
return
|
||||
}
|
||||
if (!nextCpuAlive) {
|
||||
finishRoguelikeRun(clearedCount)
|
||||
setStatus('won')
|
||||
addLog(`CPU ${cpuDifficulty ?? 1} fell first.`, 'loot')
|
||||
return
|
||||
if (!nextCpuAlive && !cpuDefeatedRef.current) {
|
||||
cpuDefeatedRef.current = true
|
||||
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
||||
}
|
||||
if (nextPlayer.enemyHealth <= 0 && nextCpu.enemyHealth <= 0) {
|
||||
addLog(`${encounter.enemyName} cleared by both teams. Choose your next edge.`, 'loot')
|
||||
if (nextPlayer.enemyHealth <= 0) {
|
||||
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||
beginUpgradePhase()
|
||||
}
|
||||
}, TICK_MS)
|
||||
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(() => {
|
||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||
@@ -828,6 +916,16 @@ export function PvPRoguelikeScreen({
|
||||
})
|
||||
}, [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(() => {
|
||||
if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return
|
||||
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
|
||||
@@ -912,7 +1010,15 @@ export function PvPRoguelikeScreen({
|
||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||
|
||||
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') {
|
||||
selectRelativeTarget(-1)
|
||||
return
|
||||
@@ -925,41 +1031,93 @@ export function PvPRoguelikeScreen({
|
||||
selectDirectTarget(Number(action.slice('targetParty'.length)) - 1)
|
||||
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 === selectedId)
|
||||
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
||||
if (nextMember?.health > 0) setSelectedId(nextMember.id)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
if (action.startsWith('ability')) {
|
||||
const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length))
|
||||
if (spell) castPlayerSpell(spell)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="game-shell" data-combat-active={status === 'playing' ? 'true' : 'false'}>
|
||||
<section className="content-screen pvp-match-screen">
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">PvP Roguelike</p>
|
||||
<h1>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h1>
|
||||
</div>
|
||||
<div className="pvp-screen-tools">
|
||||
<div className="roguelike-timing-row">
|
||||
<button
|
||||
className={`text-button ${abilityLabelMode === 'ability' ? 'active' : ''}`}
|
||||
onClick={() => setAbilityLabelMode('ability')}
|
||||
type="button"
|
||||
>
|
||||
Ability Names
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${abilityLabelMode === 'slot' ? 'active' : ''}`}
|
||||
onClick={() => setAbilityLabelMode('slot')}
|
||||
type="button"
|
||||
>
|
||||
Slot Names
|
||||
</button>
|
||||
</div>
|
||||
<button className="back-button" onClick={onExit} type="button">Leave</button>
|
||||
</div>
|
||||
</div>
|
||||
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
|
||||
difficultyName: `Stage ${stage}`,
|
||||
dungeonName: encounter.enemyName,
|
||||
contentName: 'PvP Roguelike',
|
||||
encounterName: encounter.enemyName,
|
||||
encounterDescription: encounter.description,
|
||||
encounterHealth: playerSide.enemyHealth,
|
||||
encounterMaxHealth: encounter.maxHealth,
|
||||
encounterIsBoss: encounter.isBoss,
|
||||
encounterIndex,
|
||||
encounterCount: encounters.length,
|
||||
party: playerSide.party,
|
||||
partySize: playerSide.party.length,
|
||||
selectedId,
|
||||
log,
|
||||
status: status === 'queueing' ? 'playing' : status,
|
||||
resource: playerSide.resource,
|
||||
maxResource,
|
||||
resourceName: gameClass.resourceName,
|
||||
playerIsAlive: playerAlive,
|
||||
spells: starterSpells.map((spell, slotIndex) => ({
|
||||
...spell,
|
||||
cost: spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady),
|
||||
slotIndex,
|
||||
remaining: playerSide.cooldowns[spell.id] ?? 0,
|
||||
})),
|
||||
activeDevice: lastDevice,
|
||||
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' && (
|
||||
<div className="placeholder-panel">
|
||||
<div className="placeholder-runes">P V P</div>
|
||||
@@ -967,7 +1125,14 @@ export function PvPRoguelikeScreen({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status !== 'queueing' && (
|
||||
{dualScreenEnabled && status !== 'queueing' && (
|
||||
<DualScreenTopCombat
|
||||
state={dualScreenState}
|
||||
onSelectTarget={setSelectedId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!dualScreenEnabled && status !== 'queueing' && (
|
||||
<div className="pvp-board">
|
||||
<section className="combat-panel pvp-side">
|
||||
<div className="encounter-header">
|
||||
@@ -982,7 +1147,7 @@ export function PvPRoguelikeScreen({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="party-grid">
|
||||
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
|
||||
{playerSide.party.map((member) => (
|
||||
<button
|
||||
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
|
||||
@@ -998,6 +1163,7 @@ export function PvPRoguelikeScreen({
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / 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 className="floating-combat-texts" aria-hidden="true">
|
||||
{floatingTexts
|
||||
@@ -1087,7 +1253,7 @@ export function PvPRoguelikeScreen({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="party-grid">
|
||||
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
|
||||
{cpuSide.party.map((member) => (
|
||||
<div className={`party-member ${member.health <= 0 ? 'down' : ''}`} key={`cpu-${member.id}`}>
|
||||
<div className="member-header">
|
||||
@@ -1098,6 +1264,7 @@ export function PvPRoguelikeScreen({
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / 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 className="floating-combat-texts" aria-hidden="true">
|
||||
{floatingTexts
|
||||
@@ -1125,9 +1292,6 @@ export function PvPRoguelikeScreen({
|
||||
{status === 'upgrade-choice' && (
|
||||
<div className="result-screen">
|
||||
<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>
|
||||
<strong>Self Buff</strong>
|
||||
@@ -1169,6 +1333,17 @@ export function PvPRoguelikeScreen({
|
||||
</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') && (
|
||||
<div className="result-screen">
|
||||
<div>
|
||||
@@ -1176,7 +1351,7 @@ export function PvPRoguelikeScreen({
|
||||
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
|
||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||
<div className="reward-summary">
|
||||
{!reward && !rewardError && <p>Recording roguelike progress...</p>}
|
||||
{!reward && !rewardError && <p>Boss kills grant XP immediately.</p>}
|
||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||
{reward && (
|
||||
<>
|
||||
@@ -1193,6 +1368,13 @@ export function PvPRoguelikeScreen({
|
||||
Ability Unlocked: {ability.name}
|
||||
</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>
|
||||
|
||||
+155
-126
@@ -24,6 +24,7 @@ import {
|
||||
|
||||
export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
const [device, setDevice] = useState<InputDevice>('controller')
|
||||
const [settingsTab, setSettingsTab] = useState<'display' | 'input' | 'bindings'>('display')
|
||||
const [displayMessage, setDisplayMessage] = useState('')
|
||||
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
|
||||
const {
|
||||
@@ -50,6 +51,7 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
'targetParty3',
|
||||
'targetParty4',
|
||||
'targetParty5',
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
])
|
||||
const visibleActions = INPUT_ACTIONS.filter((action) => (
|
||||
@@ -95,138 +97,165 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
</div>
|
||||
|
||||
<section className="dual-screen-settings">
|
||||
<div>
|
||||
<p className="eyebrow">Display</p>
|
||||
<h2>AYN Thor Dual-Screen Mode</h2>
|
||||
<p>
|
||||
The upper display shows enemy and party health. The lower display
|
||||
keeps targeting, resources, skills, and cooldowns.
|
||||
</p>
|
||||
</div>
|
||||
<div className="dual-screen-actions">
|
||||
<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
|
||||
className={dualScreenEnabled ? 'selected' : ''}
|
||||
onClick={() => {
|
||||
setDualScreenEnabled(!dualScreenEnabled)
|
||||
setDisplayMessage('')
|
||||
}}
|
||||
aria-selected={settingsTab === tab.key}
|
||||
className={settingsTab === tab.key ? 'selected' : ''}
|
||||
key={tab.key}
|
||||
onClick={() => setSettingsTab(tab.key)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
|
||||
</button>
|
||||
<button onClick={launchTopDisplay} type="button">
|
||||
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
|
||||
</button>
|
||||
</div>
|
||||
<small>
|
||||
{displayMessage || (
|
||||
topDisplayConnected
|
||||
? 'The companion display is connected and receiving live combat data.'
|
||||
: 'Open the companion display before starting combat.'
|
||||
)}
|
||||
</small>
|
||||
{nativeDualScreen && androidDisplays.length > 0 && (
|
||||
<div className="android-display-list">
|
||||
{androidDisplays.map((display) => (
|
||||
<span key={display.id}>
|
||||
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
|
||||
{display.width}×{display.height} at {Math.round(display.refreshRate)} Hz
|
||||
{display.isPresentation ? ' - Presentation' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
<section className="controller-preferences">
|
||||
<div>
|
||||
<p className="eyebrow">Targeting</p>
|
||||
<h3>Direct Party Keybinds</h3>
|
||||
<p>
|
||||
Assign party slots directly. In raids, use the group-switch binding
|
||||
to alternate between members 1-5 and 6-10.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-pressed={directPartyTargeting}
|
||||
className={directPartyTargeting ? 'selected' : ''}
|
||||
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
|
||||
type="button"
|
||||
>
|
||||
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
|
||||
</button>
|
||||
<div className="controller-icon-options">
|
||||
<span>Controller Icons</span>
|
||||
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
|
||||
<button
|
||||
aria-pressed={controllerIconStyle === style}
|
||||
className={controllerIconStyle === style ? 'selected' : ''}
|
||||
key={style}
|
||||
onClick={() => setControllerIconStyle(style)}
|
||||
type="button"
|
||||
>
|
||||
<ControllerStylePreview iconStyle={style} />
|
||||
<span className="controller-style-name">{CONTROLLER_STYLE_LABELS[style]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="binding-tabs">
|
||||
<button
|
||||
className={device === 'controller' ? 'selected' : ''}
|
||||
onClick={() => setDevice('controller')}
|
||||
type="button"
|
||||
>
|
||||
Controller
|
||||
</button>
|
||||
<button
|
||||
className={device === 'pc' ? 'selected' : ''}
|
||||
onClick={() => setDevice('pc')}
|
||||
type="button"
|
||||
>
|
||||
PC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="binding-list">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
className={capture?.device === device && capture.action === action ? 'listening' : ''}
|
||||
key={action}
|
||||
onClick={() => beginCapture(device, action)}
|
||||
type="button"
|
||||
>
|
||||
<span>{ACTION_LABELS[action]}</span>
|
||||
<kbd>
|
||||
{capture?.device === device && capture.action === action
|
||||
? 'Press a control...'
|
||||
: (
|
||||
<ControllerBindingLabel
|
||||
binding={bindings[device][action]}
|
||||
iconStyle={controllerIconStyle}
|
||||
/>
|
||||
)}
|
||||
</kbd>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<footer className="settings-footer">
|
||||
<span>Bindings are saved automatically on this device.</span>
|
||||
<button className="text-button" onClick={() => resetBindings(device)} type="button">
|
||||
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
|
||||
</button>
|
||||
</footer>
|
||||
{settingsTab === 'display' && (
|
||||
<section className="dual-screen-settings settings-tab-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Display</p>
|
||||
<h2>AYN Thor Dual-Screen Mode</h2>
|
||||
<p>
|
||||
The upper display shows enemy and party health. The lower display
|
||||
keeps targeting, resources, skills, and cooldowns.
|
||||
</p>
|
||||
</div>
|
||||
<div className="dual-screen-actions">
|
||||
<button
|
||||
className={dualScreenEnabled ? 'selected' : ''}
|
||||
onClick={() => {
|
||||
setDualScreenEnabled(!dualScreenEnabled)
|
||||
setDisplayMessage('')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
|
||||
</button>
|
||||
<button onClick={launchTopDisplay} type="button">
|
||||
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
|
||||
</button>
|
||||
</div>
|
||||
<small>
|
||||
{displayMessage || (
|
||||
topDisplayConnected
|
||||
? 'The companion display is connected and receiving live combat data.'
|
||||
: 'Open the companion display before starting combat.'
|
||||
)}
|
||||
</small>
|
||||
{nativeDualScreen && androidDisplays.length > 0 && (
|
||||
<div className="android-display-list">
|
||||
{androidDisplays.map((display) => (
|
||||
<span key={display.id}>
|
||||
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
|
||||
{display.width}x{display.height} at {Math.round(display.refreshRate)} Hz
|
||||
{display.isPresentation ? ' - Presentation' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{settingsTab === 'input' && (
|
||||
<section className="controller-preferences settings-tab-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Targeting</p>
|
||||
<h3>Direct Party Keybinds</h3>
|
||||
<p>
|
||||
Assign party slots directly. In raids, use the group-switch binding
|
||||
to alternate between members 1-6, 7-12, and 13-18.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-pressed={directPartyTargeting}
|
||||
className={directPartyTargeting ? 'selected' : ''}
|
||||
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
|
||||
type="button"
|
||||
>
|
||||
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
|
||||
</button>
|
||||
<div className="controller-icon-options">
|
||||
<span>Controller Icons</span>
|
||||
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
|
||||
<button
|
||||
aria-pressed={controllerIconStyle === style}
|
||||
className={controllerIconStyle === style ? 'selected' : ''}
|
||||
key={style}
|
||||
onClick={() => setControllerIconStyle(style)}
|
||||
type="button"
|
||||
>
|
||||
<ControllerStylePreview iconStyle={style} />
|
||||
<span className="controller-style-name">{CONTROLLER_STYLE_LABELS[style]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
<button
|
||||
className={device === 'controller' ? 'selected' : ''}
|
||||
onClick={() => setDevice('controller')}
|
||||
type="button"
|
||||
>
|
||||
Controller
|
||||
</button>
|
||||
<button
|
||||
className={device === 'pc' ? 'selected' : ''}
|
||||
onClick={() => setDevice('pc')}
|
||||
type="button"
|
||||
>
|
||||
PC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="binding-list">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
className={capture?.device === device && capture.action === action ? 'listening' : ''}
|
||||
key={action}
|
||||
onClick={() => beginCapture(device, action)}
|
||||
type="button"
|
||||
>
|
||||
<span>{ACTION_LABELS[action]}</span>
|
||||
<kbd>
|
||||
{capture?.device === device && capture.action === action
|
||||
? 'Press a control...'
|
||||
: (
|
||||
<ControllerBindingLabel
|
||||
binding={bindings[device][action]}
|
||||
iconStyle={controllerIconStyle}
|
||||
/>
|
||||
)}
|
||||
</kbd>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="settings-footer">
|
||||
<span>Bindings are saved automatically on this device.</span>
|
||||
<button className="text-button" onClick={() => resetBindings(device)} type="button">
|
||||
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{capture && (
|
||||
<div className="binding-capture" role="dialog" aria-modal="true">
|
||||
|
||||
@@ -15,6 +15,7 @@ type Props = {
|
||||
|
||||
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
||||
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
||||
const [talentPage, setTalentPage] = useState(0)
|
||||
const [resetting, setResetting] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const scrollRef = useRef<number>(0)
|
||||
@@ -28,6 +29,11 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
||||
const tiers = Array.from(
|
||||
new Set(gameClass.talents.map((talent) => talent.tier)),
|
||||
).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(() => {
|
||||
window.scrollTo(0, scrollRef.current)
|
||||
@@ -123,8 +129,23 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
|
||||
</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">
|
||||
{tiers.map((tier) => {
|
||||
{visibleTiers.map((tier) => {
|
||||
const requiredPoints = (tier - 1) * 5
|
||||
return (
|
||||
<section className="talent-tier" key={tier}>
|
||||
|
||||
+80
-13
@@ -11,6 +11,7 @@ import {
|
||||
} from 'react'
|
||||
import type { CombatLogEntry, PartyMember, Spell } from './game'
|
||||
import {
|
||||
getNativeDisplays,
|
||||
hasNativeDualScreenBridge,
|
||||
openNativeTopDisplay,
|
||||
} from './nativeDualScreen'
|
||||
@@ -23,6 +24,7 @@ import { ControllerBindingLabel } from './components/ControllerIcons'
|
||||
|
||||
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
|
||||
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'
|
||||
|
||||
export type DualScreenCombatState = {
|
||||
@@ -51,7 +53,7 @@ export type DualScreenCombatState = {
|
||||
controllerIconStyle: ControllerIconStyle
|
||||
directPartyTargeting: boolean
|
||||
paused: boolean
|
||||
targetGroup: 0 | 1
|
||||
targetGroup: 0 | 1 | 2
|
||||
}
|
||||
|
||||
type DualScreenMessage =
|
||||
@@ -172,6 +174,73 @@ export function useDualScreen() {
|
||||
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(
|
||||
state: DualScreenCombatState,
|
||||
enabled: boolean,
|
||||
@@ -280,9 +349,9 @@ export function DualScreenBottomDisplay() {
|
||||
<div className={`dual-controls-targets ${state.directPartyTargeting ? 'direct' : ''}`}>
|
||||
{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 memberIndex = slot - 1 + (state.partySize === 10 ? state.targetGroup * 5 : 0)
|
||||
const memberIndex = slot - 1 + (state.partySize > 6 ? state.targetGroup * 6 : 0)
|
||||
return (
|
||||
<button onClick={() => sendAction(action)} type="button" key={action}>
|
||||
<ControllerBindingLabel
|
||||
@@ -293,13 +362,13 @@ export function DualScreenBottomDisplay() {
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{state.partySize === 10 && (
|
||||
{state.partySize > 6 && (
|
||||
<button onClick={() => sendAction('toggleTargetGroup')} type="button">
|
||||
<ControllerBindingLabel
|
||||
binding={state.bindings.toggleTargetGroup}
|
||||
iconStyle={state.controllerIconStyle}
|
||||
/>{' '}
|
||||
Party Group {state.targetGroup + 1}
|
||||
Party Group {state.targetGroup + 1}/{Math.ceil(state.partySize / 6)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -396,11 +465,13 @@ export function DualScreenTopCombat({
|
||||
</section>
|
||||
|
||||
<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) => {
|
||||
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 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 (
|
||||
<button
|
||||
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
||||
@@ -418,6 +489,7 @@ export function DualScreenTopCombat({
|
||||
{member.shield > 0 && (
|
||||
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
|
||||
)}
|
||||
<em className="health-text">{Math.ceil(member.health)} / {member.maxHealth}</em>
|
||||
</div>
|
||||
{state.directPartyTargeting && targetBinding && (
|
||||
<div className="member-target-key">
|
||||
@@ -437,11 +509,6 @@ export function DualScreenTopCombat({
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export const INITIAL_PARTY: PartyMember[] = [
|
||||
{ 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: '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[] = [
|
||||
@@ -63,6 +64,14 @@ export const RAID_PARTY: PartyMember[] = [
|
||||
{ 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: '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[] = [
|
||||
|
||||
+668
-154
File diff suppressed because it is too large
Load Diff
@@ -12,4 +12,10 @@ body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
+66
-8
@@ -33,6 +33,7 @@ export const INPUT_ACTIONS = [
|
||||
'targetParty3',
|
||||
'targetParty4',
|
||||
'targetParty5',
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
'pause',
|
||||
] as const
|
||||
@@ -60,6 +61,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
|
||||
targetParty3: 'Target Party Member 3',
|
||||
targetParty4: 'Target Party Member 4',
|
||||
targetParty5: 'Target Party Member 5',
|
||||
targetParty6: 'Target Party Member 6',
|
||||
toggleTargetGroup: 'Switch Raid Target Group',
|
||||
pause: 'Pause Menu',
|
||||
}
|
||||
@@ -85,6 +87,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
targetParty3: 'F3',
|
||||
targetParty4: 'F4',
|
||||
targetParty5: 'F5',
|
||||
targetParty6: 'F6',
|
||||
toggleTargetGroup: 'Tab',
|
||||
pause: 'Escape',
|
||||
},
|
||||
@@ -108,6 +111,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
targetParty3: 'Button15',
|
||||
targetParty4: 'Button13',
|
||||
targetParty5: 'Button4',
|
||||
targetParty6: 'Button11',
|
||||
toggleTargetGroup: 'Button6',
|
||||
pause: 'Button9',
|
||||
},
|
||||
@@ -202,13 +206,19 @@ function bindingGroup(action: InputAction) {
|
||||
}
|
||||
|
||||
function isVisible(element: HTMLElement) {
|
||||
if (element.hidden || element.getAttribute('aria-hidden') === 'true') return false
|
||||
return element.getClientRects().length > 0
|
||||
}
|
||||
|
||||
function focusableElements() {
|
||||
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
|
||||
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(
|
||||
scope.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||
@@ -256,7 +266,22 @@ function moveFocus(action: InputAction) {
|
||||
const next = ranked[0]?.candidate
|
||||
if (!next) return
|
||||
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)
|
||||
}
|
||||
|
||||
function isCombatTargetAction(action: InputAction) {
|
||||
return action.startsWith('navigate')
|
||||
|| action.startsWith('targetParty')
|
||||
|| action === 'previousTarget'
|
||||
|| action === 'nextTarget'
|
||||
|| action === 'toggleTargetGroup'
|
||||
}
|
||||
|
||||
const BUTTON_LABELS: Record<number, string> = {
|
||||
@@ -372,6 +397,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const keyboardInputRef = useRef(keyboardInput)
|
||||
const previousTokensRef = useRef(new Set<string>())
|
||||
const repeatRef = useRef<Record<string, number>>({})
|
||||
const lastCombatNavigationRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
bindingsRef.current = bindings
|
||||
@@ -416,18 +442,29 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
||||
const uiOverlay = hasUiOverlay()
|
||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||
if (combatActive && !uiOverlay && isCombatTargetAction(action)) {
|
||||
const now = performance.now()
|
||||
if (now - lastCombatNavigationRef.current < 125) return
|
||||
lastCombatNavigationRef.current = now
|
||||
}
|
||||
|
||||
setLastDevice(device)
|
||||
document.documentElement.dataset.inputDevice = device
|
||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||
|
||||
if (action.startsWith('navigate')) {
|
||||
if (!combatActive) moveFocus(action)
|
||||
if (uiOverlay || !combatActive) moveFocus(action)
|
||||
} else if (action === 'confirm') {
|
||||
const active = document.activeElement
|
||||
if (isTextInput(active)) {
|
||||
setKeyboardInput(active)
|
||||
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()
|
||||
} else {
|
||||
focusFirstControl()
|
||||
@@ -435,7 +472,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
} else if (action === 'back') {
|
||||
if (keyboardInputRef.current) {
|
||||
closeKeyboard()
|
||||
} else if (!combatActive) {
|
||||
} else if (uiOverlay || !combatActive) {
|
||||
const backButton = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>('.back-button:not(:disabled)'),
|
||||
).find(isVisible)
|
||||
@@ -458,18 +495,28 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const combatActive = Boolean(
|
||||
document.querySelector('[data-combat-active="true"]'),
|
||||
)
|
||||
const uiOverlay = hasUiOverlay()
|
||||
const menuDpadActions: Partial<Record<string, InputAction>> = {
|
||||
Button12: 'navigateUp',
|
||||
Button13: 'navigateDown',
|
||||
Button14: 'navigateLeft',
|
||||
Button15: 'navigateRight',
|
||||
}
|
||||
const uiPriority = [
|
||||
'navigateUp',
|
||||
'navigateDown',
|
||||
'navigateLeft',
|
||||
'navigateRight',
|
||||
'confirm',
|
||||
'back',
|
||||
] satisfies InputAction[]
|
||||
const directTargetActions = [
|
||||
'targetParty1',
|
||||
'targetParty2',
|
||||
'targetParty3',
|
||||
'targetParty4',
|
||||
'targetParty5',
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
] satisfies InputAction[]
|
||||
const combatPriority = [
|
||||
@@ -487,7 +534,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
'navigateLeft',
|
||||
'navigateRight',
|
||||
] 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(
|
||||
(candidate) => bindingsRef.current.controller[candidate] === token,
|
||||
)
|
||||
@@ -541,8 +592,13 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const ensureFocus = () => {
|
||||
const combatActive = document.querySelector('[data-combat-active="true"]')
|
||||
if (combatActive) return
|
||||
const candidates = focusableElements()
|
||||
const active = document.activeElement
|
||||
const activeIsUsable = active instanceof HTMLElement
|
||||
&& candidates.includes(active)
|
||||
&& isVisible(active)
|
||||
if (
|
||||
document.activeElement === document.body
|
||||
(!activeIsUsable || document.activeElement === document.body)
|
||||
&& !keyboardInputRef.current
|
||||
&& !captureRef.current
|
||||
) {
|
||||
@@ -553,6 +609,8 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
window.requestAnimationFrame(ensureFocus)
|
||||
})
|
||||
observer.observe(document.getElementById('root') ?? document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
|
||||
+16
-2
@@ -1,9 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { InputProvider } from './input.tsx'
|
||||
import { DualScreenBottomDisplay, DualScreenProvider } from './dualScreen.tsx'
|
||||
import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt } from './dualScreen.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
@@ -11,6 +12,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<DualScreenBottomDisplay />
|
||||
) : (
|
||||
<DualScreenProvider>
|
||||
<DualScreenStartupPrompt />
|
||||
<InputProvider>
|
||||
<App />
|
||||
</InputProvider>
|
||||
@@ -19,7 +21,19 @@ createRoot(document.getElementById('root')!).render(
|
||||
</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', () => {
|
||||
navigator.serviceWorker.register('/service-worker.js').catch(() => {
|
||||
// Offline launch remains optional when registration is unavailable.
|
||||
|
||||
+1876
-5827
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -59,7 +59,7 @@ export type Item = {
|
||||
slug: string
|
||||
name: string
|
||||
slot: EquipmentSlot
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic'
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||
itemLevel: number
|
||||
healingPower: number
|
||||
maxResourceBonus: number
|
||||
@@ -234,6 +234,7 @@ export type Account = {
|
||||
export type AuthSession = {
|
||||
account: Account | null
|
||||
profile: CharacterProfile | null
|
||||
token?: string
|
||||
}
|
||||
|
||||
export type BonusItem = {
|
||||
@@ -247,6 +248,7 @@ export type BonusItem = {
|
||||
maxResourceBonus: number
|
||||
glyph: string
|
||||
description: string
|
||||
quantity: number
|
||||
duplicate: boolean
|
||||
quantityAfter: number
|
||||
}
|
||||
@@ -338,6 +340,8 @@ export async function completeRoguelike(
|
||||
options?: {
|
||||
bossesCleared?: number
|
||||
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
||||
lootSourceEncounterId?: number
|
||||
roguelikeStage?: number
|
||||
},
|
||||
): Promise<DungeonReward> {
|
||||
return activeGameRepository().completeRoguelike(
|
||||
@@ -374,6 +378,10 @@ export async function craftItem(recipeId: number): Promise<CharacterProfile> {
|
||||
return activeGameRepository().craftItem(recipeId)
|
||||
}
|
||||
|
||||
export async function upgradeItem(itemId: number): Promise<CharacterProfile> {
|
||||
return activeGameRepository().upgradeItem(itemId)
|
||||
}
|
||||
|
||||
export async function rollEncounterLoot(
|
||||
encounterId: number,
|
||||
difficultyId: number,
|
||||
|
||||
@@ -12,6 +12,7 @@ export type CpuPvpLeaderboardEntry = {
|
||||
}
|
||||
|
||||
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
|
||||
const checkpointKey = 'chronicle.pvpRoguelikeCheckpoint.v1'
|
||||
|
||||
export function randomCpuDifficulty(): CpuDifficulty {
|
||||
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
|
||||
@@ -44,3 +45,24 @@ export function recordCpuPvpLeaderboard(entry: CpuPvpLeaderboardEntry) {
|
||||
.slice(0, 30)
|
||||
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