4 Commits

Author SHA1 Message Date
Warren H 814eb1998d Android build v1.0.25 2026-06-18 23:28:43 -04:00
Warren H 7fe62d8c82 Android build v1.0.24 2026-06-18 23:21:00 -04:00
Warren H 3a8d5ad8c5 changes 2026-06-18 22:28:04 -04:00
Warren H a604569a2f made some changes to the UI, removed leaderboards. updated gamesaves 2026-06-18 13:00:29 -04:00
55 changed files with 5550 additions and 6380 deletions
+6
View File
@@ -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
View File
@@ -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.
+14 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+172
View File
@@ -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
View File
@@ -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"
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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 && (
+58 -43
View File
@@ -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')
+125 -7
View File
@@ -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">
+254 -72
View File
@@ -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
View File
@@ -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">
+22 -1
View File
@@ -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
View File
@@ -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>
)
}
+9
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -12,4 +12,10 @@ body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow: hidden;
}
#root {
height: 100dvh;
overflow: hidden;
}
+66 -8
View File
@@ -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
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -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,
+22
View File
@@ -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
}
+20
View File
@@ -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"]
}