Compare commits

..

9 Commits

Author SHA1 Message Date
Warren H 207fcd1a15 Android build v1.0.31 2026-06-19 23:14:56 -04:00
Warren H fd6a1ce3c7 Android build v1.0.30 2026-06-19 22:10:14 -04:00
Warren H f8b98e6b23 Android build v1.0.29 2026-06-19 21:58:23 -04:00
Warren H fc7c6488ea Android build v1.0.28 2026-06-19 21:35:17 -04:00
Warren H ba6d3b614e Android build v1.0.27 2026-06-19 21:29:44 -04:00
Warren H 88874933c3 Android build v1.0.26 2026-06-19 20:55:23 -04:00
Warren H bf12aefeeb Update game 1.0.27 2026-06-19 16:00:47 -04:00
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
47 changed files with 4571 additions and 1945 deletions
+2
View File
@@ -2,5 +2,7 @@
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz. - AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz. - AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
- User rebuilds app; do not rebuild APK unless explicitly requested. - User rebuilds app; do not rebuild APK unless explicitly requested.
- Apply game changes to both web version and mobile app version. - Apply game changes to both web version and mobile app version.
+205 -19
View File
@@ -43,40 +43,226 @@ Keep the game server bound to `127.0.0.1`. Set `TRUST_PROXY=1` only when the
server can be reached solely through your local reverse proxy. This lets account server can be reached solely through your local reverse proxy. This lets account
limits use the visitor's public IP instead of the proxy's address. limits use the visitor's public IP instead of the proxy's address.
## Separate auth server ## TrueNAS single-container hosting
The auth routes can run as their own Node process. This is useful when you want ### TrueNAS SCALE runbook
`auth.phenomrom.com` to stay available while the game server is being rebuilt or
changed.
On the TrueNAS host, run the auth process against the same project data folder: 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.
```sh Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**.
npm ci
npm run db:init Repository:
AUTH_HOST=127.0.0.1 AUTH_PORT=4174 TRUST_PROXY=1 COOKIE_SECURE=1 AUTH_CORS_ORIGINS=https://phenomrom.com npm run auth:start
```text
https://git.whoagland.com/phenom/i-want-to-heal.git
``` ```
Point `auth.phenomrom.com` at that process through HTTPS: TrueNAS paths:
```text
/mnt/usbssds/apps/iwanttoheal/app
/mnt/usbssds/apps/iwanttoheal/data
```
Create the app directory and clone the repo:
```sh
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal
cd /mnt/usbssds/apps/iwanttoheal
sudo git clone https://git.whoagland.com/phenom/i-want-to-heal.git app
```
Because the clone was run with `sudo`, give the normal TrueNAS user ownership:
```sh
sudo chown -R truenas_admin:truenas_admin /mnt/usbssds/apps/iwanttoheal
```
Create the persistent data folder:
```sh
mkdir -p /mnt/usbssds/apps/iwanttoheal/data
```
Check that the production server file exists:
```sh
ls /mnt/usbssds/apps/iwanttoheal/app/server/production.mjs
```
If that file is missing, push the latest code to `git.whoagland.com` from the
development machine, then pull on TrueNAS:
```sh
cd /mnt/usbssds/apps/iwanttoheal/app
git pull
```
If Git fails with `chmod ... Operation not permitted`, do not use a media or SMB
dataset for the repo. Git needs normal file locking and chmod behavior. Create or
use a dedicated apps dataset and clone under `/mnt/usbssds/apps/...`.
### TrueNAS app YAML
In TrueNAS:
1. Open **Apps**.
2. Open **Discover**.
3. Click the three-dot menu.
4. Choose **Install via YAML**.
5. Name the app `iwanttoheal`.
6. Paste this YAML:
```yaml
services:
iwanttoheal:
image: node:24-bookworm-slim
working_dir: /app
command: sh -lc "npm ci && npm run db:init && npm run build && npm start"
environment:
HOST: 0.0.0.0
PORT: "4173"
TRUST_PROXY: "1"
COOKIE_SECURE: "1"
CORS_ORIGINS: "http://localhost,https://localhost,capacitor://localhost,https://iwanttoheal.phenomrom.com,https://auth.phenomrom.com"
ports:
- "4173:4173"
volumes:
- /mnt/usbssds/apps/iwanttoheal/app:/app
- /mnt/usbssds/apps/iwanttoheal/data:/app/data
restart: unless-stopped
```
The app listens inside Docker on port `4173`. The database lives at
`/mnt/usbssds/apps/iwanttoheal/data/game.db` because that host directory is
mounted into the container as `/app/data`. This is persistent runtime data, not
code. Do not commit it and do not copy the Mac `data/game.db` over it during
deploys.
The startup command installs dependencies, applies schema/static-content
updates, builds the web app, and starts the production server.
Test the local TrueNAS service:
```sh
curl http://TRUENAS-IP:4173/api/auth/session
```
Expected response:
```json
{"account":null,"profile":null}
```
### Reverse proxy
Point `iwanttoheal.phenomrom.com` at the TrueNAS app through HTTPS. Do not expose
port `4173` directly to the internet. Put Caddy or another reverse proxy in
front:
```caddyfile ```caddyfile
iwanttoheal.phenomrom.com {
reverse_proxy TRUENAS-IP:4173
}
auth.phenomrom.com { auth.phenomrom.com {
reverse_proxy 127.0.0.1:4174 reverse_proxy TRUENAS-IP:4173
} }
``` ```
Build the web or mobile app with the auth base URL set separately from the game Both hostnames can point at the same container. `iwanttoheal.phenomrom.com`
API: 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 ```sh
VITE_AUTH_API_BASE_URL=https://auth.phenomrom.com npm run build curl https://iwanttoheal.phenomrom.com
curl https://auth.phenomrom.com/api/auth/session
``` ```
For a Capacitor wrapper, set `window.CAPACITOR_AUTH_API_BASE_URL` to Expected auth response:
`https://auth.phenomrom.com` the same way `window.CAPACITOR_API_BASE_URL` is set.
The app stores the returned bearer token locally and sends it with later API ```json
requests, so auth works across subdomains and inside the mobile WebView. Existing {"account":null,"profile":null}
same-origin cookie sessions still work when auth is served by the game server. ```
### App build config
For the hosted browser game, no separate auth build setting is needed. The web
app can call same-origin routes like `/api/auth/login` and `/api/profile`.
For an Android build that should use the TrueNAS-hosted game API, build with:
```sh
npm run android:apk:truenas
```
If you intentionally want Android auth calls to use `auth.phenomrom.com`, also
set `VITE_AUTH_API_BASE_URL=https://auth.phenomrom.com`. Otherwise, leave it
unset and auth uses the same base URL as the game API.
Android runs the bundled web app from a local Capacitor origin, not from
`iwanttoheal.phenomrom.com`. The hosted server must allow that origin through
CORS, which is why the TrueNAS YAML includes `http://localhost`,
`https://localhost`, and `capacitor://localhost`.
### Updating the TrueNAS game app
Push changes from the development machine to `git.whoagland.com`, then pull them
on TrueNAS:
```sh
cd /mnt/usbssds/apps/iwanttoheal/app
git pull
```
Before restarting, back up the persistent database:
```sh
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
```
Restart the `iwanttoheal` app in the TrueNAS Apps UI after pulling. The app
command runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start` on
startup, so dependency, schema, and browser bundle changes are applied each time
the container restarts.
`npm run db:init` updates schema and seeded static game content. It should not
erase accounts, characters, inventory, or save progress. Character resets are
separate manual operations and should only be run intentionally.
Normal update workflow:
```sh
# development machine
git add .
git commit -m "Update game"
git push origin main
# TrueNAS shell
cd /mnt/usbssds/apps/iwanttoheal/app
git pull
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
```
Then restart the TrueNAS app.
For the shorter checklist, see [docs/push-updates.md](docs/push-updates.md).
### Existing auth-only app
If `iwanttoheal-auth` was already created during earlier testing, the simplest
path is to stop that app and use the single `iwanttoheal` app above. The single
container serves both domains and avoids two processes sharing one SQLite file.
## Account limits ## Account limits
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.
+6 -6
View File
@@ -57,13 +57,13 @@ For an online production build, see [DEPLOYMENT.md](DEPLOYMENT.md).
- Dungeon XP rewards, level-up handling, talent-point awards, and ability unlocks - Dungeon XP rewards, level-up handling, talent-point awards, and ability unlocks
- SQLite-backed class talent trees with ranks, tiers, prerequisites, refunds, - SQLite-backed class talent trees with ranks, tiers, prerequisites, refunds,
and immediate persistence and immediate persistence
- Seven equipment slots, starter item-level 1 gear, inventory comparison, and - Nine equipment slots, empty starter inventory, craftable item-level 1 gear,
persistent equipping inventory comparison, and persistent equipping
- Aggregate item level, healing power, and resource bonuses that affect combat - Aggregate item level, healing power, and resource bonuses that affect combat
- Five SQLite-authored dungeon difficulty tiers with level gates, combat - Four playable SQLite-authored content tiers at item levels 1, 10, 20, and 25
scaling, XP multipliers, and item-level reward bands with level gates, combat scaling, XP multipliers, and reward bands
- Encounter-specific weighted loot tables for every difficulty, with authored - Gear progression through item levels 1, 5, 10, 15, 20, and 25 with
drop chances, slot pools, and item-level 5 through 25 reward variants boss-coin crafting and upgrade steps
- One live loot roll per defeated encounter, shown in the combat log and - One live loot roll per defeated encounter, shown in the combat log and
dungeon-complete summary dungeon-complete summary
- Atomic inventory awards with retry-safe roll records and stacked duplicate - Atomic inventory awards with retry-safe roll records and stacked duplicate
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 39 versionCode 49
versionName "1.0.23" versionName "1.0.31"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -11,7 +11,7 @@ import java.io.File;
public abstract class ControllerBridgeActivity extends BridgeActivity { public abstract class ControllerBridgeActivity extends BridgeActivity {
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL"; public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
private static final long DPAD_THROTTLE_MS = 125; private static final long DPAD_THROTTLE_MS = 220;
private long lastDpadDispatchAt = 0; private long lastDpadDispatchAt = 0;
@Override @Override
+121 -41
View File
@@ -25,28 +25,35 @@ WHERE slug = 'citadel-of-the-ember-crown';
INSERT OR IGNORE INTO difficulties INSERT OR IGNORE INTO difficulties
(id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description) (id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description)
VALUES VALUES
(1, 'initiate', 'Initiate', 5, 1, 1.0, 1.0, 1.0, 'Entry-level dungeon difficulty.'), (1, 'initiate', 'Initiate', 1, 1, 0.8, 0.8, 1.0, 'Entry-level dungeon difficulty for crafting the first real set.'),
(2, 'veteran', 'Veteran', 10, 5, 1.35, 1.2, 1.5, 'Enemies deal more damage and drop stronger gear.'), (2, 'veteran', 'Veteran', 10, 10, 1.45, 1.25, 2.0, 'A major step up that rewards refined gear components.'),
(3, 'champion', 'Champion', 15, 10, 1.7, 1.45, 2.2, 'Demanding encounters for developed characters.'), (3, 'champion', 'Champion', 15, 15, 1.7, 1.45, 2.2, 'Gear-only upgrade tier between Veteran and Mythic.'),
(4, 'mythic', 'Mythic', 20, 15, 2.1, 1.75, 3.0, 'Endgame dungeon difficulty.'), (4, 'mythic', 'Mythic', 20, 20, 2.25, 1.85, 3.5, 'Endgame dungeon difficulty.'),
(5, 'ascendant', 'Ascendant', 25, 20, 2.6, 2.1, 4.0, 'The current pinnacle difficulty.'), (5, 'ascendant', 'Ascendant', 25, 25, 2.8, 2.25, 4.5, 'The current pinnacle difficulty.'),
(101, 'raid-normal', 'Normal', 7, 1, 1.0, 1.0, 1.25, 'The opening raid difficulty, tuned for an eighteen-player party.'); (101, 'raid-normal', 'Normal', 7, 1, 1.0, 1.0, 1.25, 'The opening raid difficulty, tuned for an eighteen-player party.');
UPDATE difficulties SET UPDATE difficulties SET
dropped_item_level = CASE slug dropped_item_level = CASE slug
WHEN 'raid-normal' THEN 10 ELSE dropped_item_level END, WHEN 'initiate' THEN 1 WHEN 'raid-normal' THEN 10 ELSE dropped_item_level END,
unlock_level = CASE slug unlock_level = CASE slug
WHEN 'initiate' THEN 1 WHEN 'veteran' THEN 5 WHEN 'champion' THEN 10 WHEN 'initiate' THEN 1 WHEN 'veteran' THEN 10 WHEN 'champion' THEN 15
WHEN 'mythic' THEN 15 WHEN 'ascendant' THEN 20 ELSE unlock_level END, WHEN 'mythic' THEN 20 WHEN 'ascendant' THEN 25 ELSE unlock_level END,
health_multiplier = CASE slug health_multiplier = CASE slug
WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 1.35 WHEN 'champion' THEN 1.7 WHEN 'initiate' THEN 0.8 WHEN 'veteran' THEN 1.45 WHEN 'champion' THEN 1.7
WHEN 'mythic' THEN 2.1 WHEN 'ascendant' THEN 2.6 ELSE health_multiplier END, WHEN 'mythic' THEN 2.25 WHEN 'ascendant' THEN 2.8 ELSE health_multiplier END,
damage_multiplier = CASE slug damage_multiplier = CASE slug
WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 1.2 WHEN 'champion' THEN 1.45 WHEN 'initiate' THEN 0.8 WHEN 'veteran' THEN 1.25 WHEN 'champion' THEN 1.45
WHEN 'mythic' THEN 1.75 WHEN 'ascendant' THEN 2.1 ELSE damage_multiplier END, WHEN 'mythic' THEN 1.85 WHEN 'ascendant' THEN 2.25 ELSE damage_multiplier END,
experience_multiplier = CASE slug experience_multiplier = CASE slug
WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 1.5 WHEN 'champion' THEN 2.2 WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 2.0 WHEN 'champion' THEN 2.2
WHEN 'mythic' THEN 3.0 WHEN 'ascendant' THEN 4.0 ELSE experience_multiplier END; WHEN 'mythic' THEN 3.5 WHEN 'ascendant' THEN 4.5 ELSE experience_multiplier END,
description = CASE slug
WHEN 'initiate' THEN 'Entry-level dungeon difficulty for crafting the first real set.'
WHEN 'veteran' THEN 'A major step up that rewards refined gear components.'
WHEN 'champion' THEN 'Gear-only upgrade tier between Veteran and Mythic.'
WHEN 'mythic' THEN 'Endgame dungeon difficulty.'
WHEN 'ascendant' THEN 'The current pinnacle difficulty.'
ELSE description END;
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id) VALUES INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id) VALUES
(1, 1), (1, 1),
@@ -127,22 +134,22 @@ INSERT OR IGNORE INTO spells
VALUES VALUES
(1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'), (1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'),
(2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'), (2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'),
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to every living party member.'), (3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to up to 4 injured party members.'),
(4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'), (4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'),
(5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'), (5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'),
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for the entire party.'), (6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for up to 4 injured allies.'),
(7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'), (7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'),
(8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'), (8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'),
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods the party with the full strength of dawn.'), (9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods up to 4 injured allies with the full strength of dawn.'),
(20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'), (20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'),
(21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'), (21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'),
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads through the party.'), (22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads to up to 4 injured allies.'),
(23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'), (23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'),
(24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'), (24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'),
(25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'), (25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'),
(30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'), (30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'),
(31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'), (31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'),
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links the party through a shared healing pattern.'), (32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links up to 4 injured allies through a shared healing pattern.'),
(33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'), (33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'),
(34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'), (34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'),
(35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.'); (35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.');
@@ -345,6 +352,9 @@ DELETE FROM crafting_recipes;
INSERT INTO crafting_recipes INSERT INTO crafting_recipes
(id, item_id, difficulty_id, source_dungeon_id, source_encounter_id) (id, item_id, difficulty_id, source_dungeon_id, source_encounter_id)
VALUES VALUES
(901, 101, 1, 1, 3), (902, 102, 1, 1, 3), (903, 103, 1, 1, 3),
(904, 104, 1, 1, 12), (905, 105, 1, 1, 12), (906, 106, 1, 1, 12),
(907, 100, 1, 1, 22), (908, 108, 1, 1, 22), (909, 109, 1, 1, 22),
(1001, 5, 1, 1, 3), (1002, 2, 1, 1, 3), (1003, 6, 1, 1, 3), (1001, 5, 1, 1, 3), (1002, 2, 1, 1, 3), (1003, 6, 1, 1, 3),
(1004, 4, 1, 1, 12), (1005, 1, 1, 1, 12), (1006, 7, 1, 1, 12), (1004, 4, 1, 1, 12), (1005, 1, 1, 1, 12), (1006, 7, 1, 1, 12),
(1007, 3, 1, 1, 22), (1008, 8, 1, 1, 22), (1009, 9, 1, 1, 22), (1007, 3, 1, 1, 22), (1008, 8, 1, 1, 22), (1009, 9, 1, 1, 22),
@@ -429,6 +439,10 @@ INSERT OR IGNORE INTO character_inventory (character_id, item_id, quantity, equi
(3, 100, 1, 1), (3, 101, 1, 1), (3, 102, 1, 1), (3, 103, 1, 1), (3, 100, 1, 1), (3, 101, 1, 1), (3, 102, 1, 1), (3, 103, 1, 1),
(3, 104, 1, 1), (3, 108, 1, 1), (3, 105, 1, 1), (3, 109, 1, 1), (3, 106, 1, 1), (3, 107, 1, 0); (3, 104, 1, 1), (3, 108, 1, 1), (3, 105, 1, 1), (3, 109, 1, 1), (3, 106, 1, 1), (3, 107, 1, 0);
DELETE FROM character_inventory
WHERE character_id IN (1, 2, 3)
AND item_id BETWEEN 100 AND 109;
-- Coin gearing override: every boss/difficulty drops one boss coin, and each -- Coin gearing override: every boss/difficulty drops one boss coin, and each
-- craft costs the target item level in that source boss coin. -- craft costs the target item level in that source boss coin.
UPDATE crafting_recipes UPDATE crafting_recipes
@@ -452,20 +466,20 @@ WHERE id BETWEEN 1001 AND 1409
UPDATE crafting_recipes UPDATE crafting_recipes
SET difficulty_id = CASE SET difficulty_id = CASE
(SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id) (SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id)
WHEN 1 THEN 1
WHEN 5 THEN 1 WHEN 5 THEN 1
WHEN 10 THEN 2 WHEN 10 THEN 2
WHEN 15 THEN 3 WHEN 15 THEN 2
WHEN 20 THEN 4 WHEN 20 THEN 4
WHEN 25 THEN 5 WHEN 25 THEN 5
ELSE difficulty_id ELSE difficulty_id
END END
WHERE id BETWEEN 1001 AND 1409; WHERE id BETWEEN 901 AND 1409;
UPDATE items UPDATE items
SET rarity = CASE item_level SET rarity = CASE item_level
WHEN 5 THEN 'common' WHEN 1 THEN 'common'
WHEN 10 THEN 'uncommon' WHEN 10 THEN 'uncommon'
WHEN 15 THEN 'rare'
WHEN 20 THEN 'epic' WHEN 20 THEN 'epic'
WHEN 25 THEN 'legendary' WHEN 25 THEN 'legendary'
ELSE rarity ELSE rarity
@@ -476,9 +490,8 @@ UPDATE items
SET name = ( SET name = (
SELECT SELECT
CASE items.item_level CASE items.item_level
WHEN 5 THEN '' WHEN 1 THEN 'Raw '
WHEN 10 THEN 'Green ' WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple ' WHEN 20 THEN 'Purple '
WHEN 25 THEN 'Orange ' WHEN 25 THEN 'Orange '
ELSE '' ELSE ''
@@ -532,7 +545,8 @@ SELECT
difficulties.dropped_item_level, difficulties.dropped_item_level,
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level, encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
CASE difficulties.dropped_item_level CASE difficulties.dropped_item_level
WHEN 5 THEN '' WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
WHEN 10 THEN 'Green ' WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue ' WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple ' WHEN 20 THEN 'Purple '
@@ -540,6 +554,7 @@ SELECT
ELSE '' ELSE ''
END || encounters.name || ' Coin', END || encounters.name || ' Coin',
CASE difficulties.dropped_item_level CASE difficulties.dropped_item_level
WHEN 1 THEN 'common'
WHEN 5 THEN 'common' WHEN 5 THEN 'common'
WHEN 10 THEN 'uncommon' WHEN 10 THEN 'uncommon'
WHEN 15 THEN 'rare' WHEN 15 THEN 'rare'
@@ -709,7 +724,6 @@ INSERT INTO generated_loot_tiers
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity) (item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
VALUES VALUES
(10, 3, 2, 2, 101, 1100, 2), (10, 3, 2, 2, 101, 1100, 2),
(15, 4, 5, 3, 103, 1200, 3),
(20, 6, 7, 4, 104, 1300, 4), (20, 6, 7, 4, 104, 1300, 4),
(25, 8, 9, 5, 105, 1400, 5); (25, 8, 9, 5, 105, 1400, 5);
@@ -792,19 +806,58 @@ VALUES
UPDATE difficulties UPDATE difficulties
SET dropped_item_level = 10, SET dropped_item_level = 10,
unlock_level = 5, unlock_level = 10,
health_multiplier = 1.35, health_multiplier = 1.45,
damage_multiplier = 1.2, damage_multiplier = 1.25,
experience_multiplier = 1.75, experience_multiplier = 2.0,
description = 'Veteran raid difficulty with extra monster-part drops.' description = 'Veteran raid difficulty with extra monster-part drops.'
WHERE id = 101; WHERE id = 101;
INSERT OR IGNORE INTO difficulties INSERT OR IGNORE INTO difficulties
(id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description) (id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description)
VALUES VALUES
(103, 'raid-champion', 'Champion Raid', 15, 10, 1.7, 1.45, 2.4, 'Champion raid difficulty with extra monster-part drops.'), (103, 'raid-champion', 'Champion Raid', 15, 15, 1.7, 1.45, 2.4, 'Gear-only raid upgrade tier between Veteran and Mythic.'),
(104, 'raid-mythic', 'Mythic Raid', 20, 15, 2.1, 1.75, 3.2, 'Mythic raid difficulty with extra monster-part drops.'), (104, 'raid-mythic', 'Mythic Raid', 20, 20, 2.25, 1.85, 3.5, 'Mythic raid difficulty with extra monster-part drops.'),
(105, 'raid-ascendant', 'Ascendant Raid', 25, 20, 2.6, 2.1, 4.2, 'Ascendant raid difficulty with extra monster-part drops.'); (105, 'raid-ascendant', 'Ascendant Raid', 25, 25, 2.8, 2.25, 4.5, 'Ascendant raid difficulty with extra monster-part drops.');
UPDATE difficulties
SET dropped_item_level = CASE id
WHEN 103 THEN 15
WHEN 104 THEN 20
WHEN 105 THEN 25
ELSE dropped_item_level
END,
unlock_level = CASE id
WHEN 103 THEN 15
WHEN 104 THEN 20
WHEN 105 THEN 25
ELSE unlock_level
END,
health_multiplier = CASE id
WHEN 103 THEN 1.7
WHEN 104 THEN 2.25
WHEN 105 THEN 2.8
ELSE health_multiplier
END,
damage_multiplier = CASE id
WHEN 103 THEN 1.45
WHEN 104 THEN 1.85
WHEN 105 THEN 2.25
ELSE damage_multiplier
END,
experience_multiplier = CASE id
WHEN 103 THEN 2.4
WHEN 104 THEN 3.5
WHEN 105 THEN 4.5
ELSE experience_multiplier
END,
description = CASE id
WHEN 103 THEN 'Gear-only raid upgrade tier between Veteran and Mythic.'
WHEN 104 THEN 'Mythic raid difficulty with extra monster-part drops.'
WHEN 105 THEN 'Ascendant raid difficulty with extra monster-part drops.'
ELSE description
END
WHERE id IN (103, 104, 105);
DELETE FROM dungeon_difficulties WHERE dungeon_id = 2 AND difficulty_id <> 101; DELETE FROM dungeon_difficulties WHERE dungeon_id = 2 AND difficulty_id <> 101;
@@ -1161,6 +1214,19 @@ SET slug = CASE id
END END
WHERE id BETWEEN 860 AND 871; WHERE id BETWEEN 860 AND 871;
DELETE FROM dungeon_difficulties;
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id) VALUES
(1, 1),
(1, 2),
(1, 4),
(1, 5),
(3, 2),
(6, 4),
(8, 5),
(2, 101),
(7, 104),
(9, 105);
DELETE FROM crafting_recipe_components WHERE recipe_id BETWEEN 1001 AND 1009; DELETE FROM crafting_recipe_components WHERE recipe_id BETWEEN 1001 AND 1009;
INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES
@@ -1196,20 +1262,33 @@ WHERE id BETWEEN 1001 AND 1409
UPDATE crafting_recipes UPDATE crafting_recipes
SET difficulty_id = CASE SET difficulty_id = CASE
(SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id) (SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id)
WHEN 1 THEN 1
WHEN 5 THEN 1 WHEN 5 THEN 1
WHEN 10 THEN 2 WHEN 10 THEN 2
WHEN 15 THEN 3 WHEN 15 THEN 2
WHEN 20 THEN 4 WHEN 20 THEN 4
WHEN 25 THEN 5 WHEN 25 THEN 5
ELSE difficulty_id ELSE difficulty_id
END END
WHERE id BETWEEN 1001 AND 1409; WHERE id BETWEEN 901 AND 1409;
DELETE FROM crafting_recipe_components
WHERE recipe_id IN (
SELECT crafting_recipes.id
FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id
WHERE items.item_level NOT IN (1, 10, 20, 25)
);
DELETE FROM crafting_recipes
WHERE item_id IN (
SELECT id FROM items WHERE item_level NOT IN (1, 10, 20, 25)
);
UPDATE items UPDATE items
SET rarity = CASE item_level SET rarity = CASE item_level
WHEN 5 THEN 'common' WHEN 1 THEN 'common'
WHEN 10 THEN 'uncommon' WHEN 10 THEN 'uncommon'
WHEN 15 THEN 'rare'
WHEN 20 THEN 'epic' WHEN 20 THEN 'epic'
WHEN 25 THEN 'legendary' WHEN 25 THEN 'legendary'
ELSE rarity ELSE rarity
@@ -1220,9 +1299,8 @@ UPDATE items
SET name = ( SET name = (
SELECT SELECT
CASE items.item_level CASE items.item_level
WHEN 5 THEN '' WHEN 1 THEN 'Raw '
WHEN 10 THEN 'Green ' WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple ' WHEN 20 THEN 'Purple '
WHEN 25 THEN 'Orange ' WHEN 25 THEN 'Orange '
ELSE '' ELSE ''
@@ -1264,7 +1342,8 @@ SELECT
difficulties.dropped_item_level, difficulties.dropped_item_level,
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level, encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
CASE difficulties.dropped_item_level CASE difficulties.dropped_item_level
WHEN 5 THEN '' WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
WHEN 10 THEN 'Green ' WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue ' WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple ' WHEN 20 THEN 'Purple '
@@ -1272,6 +1351,7 @@ SELECT
ELSE '' ELSE ''
END || encounters.name || ' Coin', END || encounters.name || ' Coin',
CASE difficulties.dropped_item_level CASE difficulties.dropped_item_level
WHEN 1 THEN 'common'
WHEN 5 THEN 'common' WHEN 5 THEN 'common'
WHEN 10 THEN 'uncommon' WHEN 10 THEN 'uncommon'
WHEN 15 THEN 'rare' WHEN 15 THEN 'rare'
+105 -138
View File
@@ -1,172 +1,139 @@
# Gearing System # Gearing System
## Goal ## Current Rule
Gearing should move from boss-specific multi-item drop tables to one clear currency loop: The game uses fewer playable content tiers and more gear upgrade steps.
1. Kill bosses. Content tiers:
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. | Content Tier | Unlock Level | Purpose |
| --- | ---: | --- |
| iLvl 1 | 1 | First real gear set |
| iLvl 10 | 10 | Midgame jump |
| iLvl 20 | 20 | Endgame jump |
| iLvl 25 | 25 | Hard endgame |
Gear tiers:
| Gear Tier | How It Is Used |
| ---: | --- |
| 1 | Crafted from iLvl 1 content |
| 5 | Upgrade tier from iLvl 1 content coins |
| 10 | Crafted/upgraded from iLvl 10 content coins |
| 15 | Gear-only upgrade tier from iLvl 10 content coins |
| 20 | Crafted/upgraded from iLvl 20 content coins |
| 25 | Crafted/upgraded from iLvl 25 content coins |
This keeps the dungeon/raid picker simple while still giving players steady gear goals.
## New Characters
New characters start with no gear equipped.
The first goal is to clear iLvl 1 content, earn raw boss coins, and craft the first iLvl 1 set. Starter gear exists as craftable gear, not automatic inventory.
## Coin Tiers ## Coin Tiers
Coins are component items. Each coin is tied to a boss source and an item-level tier. Coins are component items. Each coin is tied to a boss and a content tier.
| Item Level | Display Color | Rarity Key | Example | | Content Tier | Coin Prefix | Rarity Key | Example |
| --- | --- | --- | --- | | ---: | --- | --- | --- |
| 5 | White | common | Bulldrome Coin | | 1 | Raw | common | Raw Bulldrome Coin |
| 10 | Green | uncommon | Green Bulldrome Coin | | 10 | Green | uncommon | Green Bulldrome Coin |
| 15 | Blue | rare | Blue Bulldrome Coin |
| 20 | Purple | epic | Purple Bulldrome Coin | | 20 | Purple | epic | Purple Bulldrome Coin |
| 25 | Orange | legendary | Orange 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. iLvl 5 and iLvl 15 gear do not have their own playable content tier. They use coins from the previous playable tier:
| Gear Tier | Coin Tier Used |
| ---: | ---: |
| 1 | 1 |
| 5 | 1 |
| 10 | 10 |
| 15 | 10 |
| 20 | 20 |
| 25 | 25 |
## Boss Loot ## Boss Loot
Each boss has one loot roll. Each boss drops one boss coin for the selected content tier.
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: Examples:
| Upgrade | Cost | - Bulldrome at iLvl 1 drops Raw Bulldrome Coins.
| --- | --- | - Tigrex at iLvl 10 drops Green Tigrex Coins.
| Bulldrome item level 5 gear -> Bulldrome item level 10 gear | 10 green Bulldrome coins | - Barroth at iLvl 20 drops Purple Barroth Coins.
| Bulldrome item level 10 gear -> Bulldrome item level 15 gear | 15 blue Bulldrome coins | - Anjanath at iLvl 25 drops Orange Anjanath 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. ## Crafting And Upgrades
The first gear item in a boss/item line can be crafted directly. Higher versions should be reached through Upgrade.
Current rule:
- Craft iLvl 1 boss gear directly.
- Upgrade iLvl 1 -> 5 with iLvl 1 coins.
- Craft or upgrade iLvl 10 gear from iLvl 10 content.
- Upgrade iLvl 10 -> 15 with iLvl 10 coins.
- Craft or upgrade iLvl 20 gear from iLvl 20 content.
- Craft or upgrade iLvl 25 gear from iLvl 25 content.
Upgrade consumes the old item and awards the upgraded item. This avoids duplicate clutter and keeps item identity clear.
Examples:
| Upgrade | Cost Source |
| --- | --- |
| Raw Bulldrome Helmet iLvl 1 -> Honed Bulldrome Helmet iLvl 5 | Raw Bulldrome Coins |
| Green Tigrex Helmet iLvl 10 -> Blue Tigrex Helmet iLvl 15 | Green Tigrex Coins |
| Purple Bulldrome Helmet iLvl 20 -> Orange Bulldrome Helmet iLvl 25 | Orange Bulldrome Coins |
## UI Behavior
The dungeon and raid picker only shows playable content tiers:
```text
iLvl 1 -> iLvl 10 -> iLvl 20 -> iLvl 25
```
The equipment screen still shows gear recipes for:
```text
iLvl 1 -> iLvl 5 -> iLvl 10 -> iLvl 15 -> iLvl 20 -> iLvl 25
```
Direct crafting is blocked for recipes that have a lower item-level version in the same boss/item line. Use the selected item's Upgrade button for those.
## Roguelike Loot ## Roguelike Loot
Roguelike bosses should award coins when defeated, using the same 1 to 3 coin roll. Roguelike gear should follow the same tier brackets.
Roguelike coin tier should scale by wave band: Recommended mapping:
| Waves | Coin Tier | | Stage Band | Coin Tier |
| --- | --- | | --- | ---: |
| 1-4 | Level 5 white coins | | 1-4 | 1 |
| 5-9 | Level 10 green coins | | 5-9 | 10 |
| 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 | | 10-14 | 10 |
| 15-19 | 15 | | 15-19 | 20 |
| 20+ | Highest unlocked 5-wave checkpoint | | 20+ | 25 |
Checkpoint rule: Boss-based coins are still preferred. A roguelike boss should award coins for its actual boss template when possible.
- Unlock a checkpoint after clearing its boss band. ## Data Notes
- 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. Authoritative gearing data lives in SQLite seed data:
## Current Code Fit - `db/seed.sql`
- `src/offline-starter-profile.json`
The existing system already has most of the required foundation: Run this after changing seed data:
- `items.slot = 'component'` can represent coins. ```sh
- `character_inventory.quantity` already stacks components. npm run db:init
- `crafting_recipes` and `crafting_recipe_components` already support coin costs. npm run offline:export
- `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: `npm run build` already runs `offline:export`, so a production build also refreshes the offline starter profile.
- Replace current 4-component boss drop tables with one boss coin per boss per tier. TrueNAS keeps its own persistent `data/game.db`. Pushing code does not merge or replace that database. The TrueNAS app applies seed/schema changes when the container starts and runs `npm run db:init`.
- 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.
+164
View File
@@ -0,0 +1,164 @@
# Push Updates
Use this when pushing code from the Mac to `git.whoagland.com`, then updating the TrueNAS-hosted server.
## Rules
- Git deploys code only.
- TrueNAS save data lives in `/mnt/usbssds/apps/iwanttoheal/data/game.db`.
- Do not commit, copy, or replace `data/game.db`.
- Do not run character reset commands unless you intentionally want a wipe.
- Restarting the TrueNAS app runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start`.
## Step 1: Build Web Locally
```sh
cd /Users/warren/Documents/testgame/testgame
npm run build
```
This validates TypeScript, builds the browser app, and refreshes `src/offline-starter-profile.json`.
## Step 2: Optional Android APK
Only run this when building a new APK.
```sh
set -e
cd /Users/warren/Documents/testgame/testgame
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
export PATH="$JAVA_HOME/bin:$PATH"
VERSION="1.0.27"
CURRENT_CODE=$(grep -E 'versionCode [0-9]+' android/app/build.gradle | awk '{print $2}')
NEXT_CODE=$((CURRENT_CODE + 1))
perl -0pi -e "s/versionCode\s+\d+/versionCode $NEXT_CODE/" android/app/build.gradle
perl -0pi -e "s/versionName\s+\"[^\"]+\"/versionName \"$VERSION\"/" android/app/build.gradle
VITE_API_BASE_URL="https://iwanttoheal.phenomrom.com" npm run android:sync
cd android
./gradlew clean assembleDebug
cd ..
cp android/app/build/outputs/apk/debug/app-debug.apk "IWantToHeal-Thor-v$VERSION.apk"
ls -lh "IWantToHeal-Thor-v$VERSION.apk"
```
## Step 3: Commit And Push Code
```sh
cd /Users/warren/Documents/testgame/testgame
git add .
git commit -m "Update game 1.0.27"
git push origin main
```
Check before committing:
```sh
git status --short
```
Expected: source/doc/build-output changes only. Do not stage `data/game.db`.
## Step 4: Optional Gitea Release For APK
Only run this when Step 2 created a new APK.
```sh
set -e
cd /Users/warren/Documents/testgame/testgame
export GITEA_URL="https://git.whoagland.com"
export GITEA_OWNER="phenom"
export GITEA_REPO="i-want-to-heal"
export GITEA_TOKEN="PASTE_TOKEN_HERE"
VERSION="1.0.26"
APK="IWantToHeal-Thor-v$VERSION.apk"
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"v$VERSION\",\"target_commitish\":\"main\",\"name\":\"v$VERSION\",\"body\":\"I Want to Heal Android build v$VERSION\",\"draft\":false,\"prerelease\":false}")
RELEASE_ID=$(python3 -c 'import sys,json; data=json.load(sys.stdin); print(data.get("id") or data)' <<< "$RELEASE_JSON")
curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$APK" \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$APK"
```
## Step 5: Update TrueNAS
```sh
cd /mnt/usbssds/apps/iwanttoheal/app
git pull
```
Before restarting, make a DB backup:
```sh
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
```
Then restart the `iwanttoheal` app in the TrueNAS Apps UI.
## What Happens On Restart
The app command runs:
```sh
npm ci && npm run db:init && npm run build && npm start
```
That means:
- dependency changes apply
- schema changes apply
- seed/static-content updates apply
- browser files rebuild
- existing accounts and characters stay in `data/game.db`
`npm run db:init` should not wipe saves. Character wipes are separate manual reset operations.
## Resetting TrueNAS Characters
Only run a reset when intentionally starting everyone over.
Resetting the Mac database does not reset TrueNAS. TrueNAS has its own persistent DB at:
```text
/mnt/usbssds/apps/iwanttoheal/data/game.db
```
Back it up first, then run the reset command or reset SQL on TrueNAS.
## If Something Looks Wrong
Check the mounted DB path:
```sh
ls -lh /mnt/usbssds/apps/iwanttoheal/data/game.db
```
Check the latest code:
```sh
cd /mnt/usbssds/apps/iwanttoheal/app
git log --oneline -5
```
Check the app API:
```sh
curl http://127.0.0.1:4173/api/auth/session
```
+60
View File
@@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#111218"/>
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
<text x="1075" y="88" fill="#e5b95f" font-family="monospace" font-size="18">B Back</text>
<text x="78" y="158" fill="#e5b95f" font-family="monospace" font-size="18">Pick Dungeon</text>
<g>
<rect x="78" y="178" width="335" height="128" fill="#24262f" stroke="#e5b95f" stroke-width="5"/>
<rect x="94" y="194" width="72" height="72" fill="#481e29" stroke="#090a0d" stroke-width="3"/>
<text x="116" y="241" fill="#ef6574" font-family="monospace" font-size="28" font-weight="700">AH</text>
<text x="184" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls</text>
<text x="184" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 1 • 6 Players</text>
<text x="184" y="276" fill="#e5b95f" font-family="monospace" font-size="16">Selected</text>
</g>
<g>
<rect x="436" y="178" width="335" height="128" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<rect x="452" y="194" width="72" height="72" fill="#2a263f" stroke="#090a0d" stroke-width="3"/>
<text x="474" y="241" fill="#8ca9ff" font-family="monospace" font-size="28" font-weight="700">SC</text>
<text x="542" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt</text>
<text x="542" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 5 • 6 Players</text>
</g>
<g opacity="0.65">
<rect x="794" y="178" width="335" height="128" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<rect x="810" y="194" width="72" height="72" fill="#223226" stroke="#090a0d" stroke-width="3"/>
<text x="832" y="241" fill="#70d990" font-family="monospace" font-size="28" font-weight="700">GM</text>
<text x="900" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Grove Maw</text>
<text x="900" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 10 • 6 Players</text>
</g>
<text x="78" y="356" fill="#e5b95f" font-family="monospace" font-size="18">Pick Part</text>
<g>
<rect x="78" y="376" width="282" height="82" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
<text x="112" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 1</text>
<text x="250" y="426" fill="#8f90a0" font-family="monospace" font-size="16">3 fights</text>
</g>
<g>
<rect x="382" y="376" width="282" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="416" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 2</text>
<text x="554" y="426" fill="#8f90a0" font-family="monospace" font-size="16">Locked</text>
</g>
<g>
<rect x="686" y="376" width="282" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="720" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 3</text>
<text x="858" y="426" fill="#8f90a0" font-family="monospace" font-size="16">Locked</text>
</g>
<text x="78" y="508" fill="#e5b95f" font-family="monospace" font-size="18">Pick Difficulty</text>
<rect x="78" y="528" width="240" height="64" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
<text x="116" y="568" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Normal</text>
<rect x="342" y="528" width="240" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="380" y="568" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Heroic</text>
<rect x="606" y="528" width="240" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="644" y="568" fill="#777988" font-family="monospace" font-size="20" font-weight="700">Mythic L10</text>
<rect x="914" y="508" width="238" height="86" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
<text x="982" y="559" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Start</text>
<text x="78" y="638" fill="#aaa9b7" font-family="monospace" font-size="17">Idea A: All choices are button grids. D-pad works everywhere. No native dropdown.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

+44
View File
@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#111218"/>
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
<text x="1014" y="88" fill="#e5b95f" font-family="monospace" font-size="18">LB/RB Change</text>
<rect x="78" y="150" width="760" height="398" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<rect x="112" y="184" width="164" height="164" fill="#481e29" stroke="#090a0d" stroke-width="4"/>
<text x="151" y="280" fill="#ef6574" font-family="monospace" font-size="48" font-weight="700">AH</text>
<text x="306" y="202" fill="#8f90a0" font-family="monospace" font-size="18">CURRENT DUNGEON</text>
<text x="306" y="248" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Ashen Halls</text>
<text x="306" y="286" fill="#aaa9b7" font-family="monospace" font-size="18">Guide a six-player party through burning halls.</text>
<text x="306" y="324" fill="#e5b95f" font-family="monospace" font-size="18">Level 1 • 6 Players • 100 XP</text>
<rect x="112" y="384" width="690" height="104" fill="#15161c" stroke="#33343d" stroke-width="3"/>
<text x="138" y="425" fill="#f2f0dc" font-family="monospace" font-size="19">Ashen Halls</text>
<text x="356" y="425" fill="#8f90a0" font-family="monospace" font-size="19">Sunken Crypt</text>
<text x="608" y="425" fill="#8f90a0" font-family="monospace" font-size="19">Grove Maw</text>
<rect x="128" y="448" width="146" height="8" fill="#e5b95f"/>
<rect x="874" y="150" width="278" height="398" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="906" y="194" fill="#e5b95f" font-family="monospace" font-size="18">Setup</text>
<text x="906" y="232" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Part</text>
<rect x="906" y="252" width="68" height="54" fill="#29291f" stroke="#e5b95f" stroke-width="4"/>
<text x="932" y="286" fill="#f2f0dc" font-family="monospace" font-size="20">1</text>
<rect x="990" y="252" width="68" height="54" fill="#20222a" stroke="#41404a" stroke-width="3"/>
<text x="1016" y="286" fill="#8f90a0" font-family="monospace" font-size="20">2</text>
<rect x="1074" y="252" width="68" height="54" fill="#20222a" stroke="#41404a" stroke-width="3"/>
<text x="1100" y="286" fill="#8f90a0" font-family="monospace" font-size="20">3</text>
<text x="906" y="350" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Difficulty</text>
<rect x="906" y="372" width="236" height="52" fill="#29291f" stroke="#e5b95f" stroke-width="4"/>
<text x="938" y="405" fill="#f2f0dc" font-family="monospace" font-size="18">Normal</text>
<rect x="906" y="436" width="236" height="52" fill="#20222a" stroke="#41404a" stroke-width="3"/>
<text x="938" y="469" fill="#aaa9b7" font-family="monospace" font-size="18">Heroic</text>
<rect x="78" y="580" width="238" height="70" fill="#15161c" stroke="#41404a" stroke-width="4"/>
<text x="120" y="624" fill="#e5b95f" font-family="monospace" font-size="22">Prev</text>
<rect x="342" y="580" width="238" height="70" fill="#15161c" stroke="#41404a" stroke-width="4"/>
<text x="392" y="624" fill="#e5b95f" font-family="monospace" font-size="22">Next</text>
<rect x="874" y="580" width="278" height="70" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
<text x="963" y="624" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Start</text>
<text x="78" y="684" fill="#aaa9b7" font-family="monospace" font-size="17">Idea B: One big focused dungeon. Shoulder buttons or side buttons cycle dungeon.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+48
View File
@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#111218"/>
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Mission Board</text>
<text x="1046" y="88" fill="#e5b95f" font-family="monospace" font-size="18">A Start</text>
<rect x="78" y="150" width="500" height="474" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="112" y="194" fill="#e5b95f" font-family="monospace" font-size="18">Available Runs</text>
<g>
<rect x="112" y="222" width="432" height="82" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
<text x="136" y="255" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 1</text>
<text x="136" y="283" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • iLvl 1 • 100 XP</text>
</g>
<g>
<rect x="112" y="318" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="136" y="351" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 1</text>
<text x="136" y="379" fill="#aaa9b7" font-family="monospace" font-size="16">Heroic • iLvl 5 • 140 XP</text>
</g>
<g opacity="0.65">
<rect x="112" y="414" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="136" y="447" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt • Part 1</text>
<text x="136" y="475" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • Locked Level 5</text>
</g>
<g opacity="0.65">
<rect x="112" y="510" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="136" y="543" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 2</text>
<text x="136" y="571" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • Locked until Part 1 clear</text>
</g>
<rect x="616" y="150" width="536" height="474" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<rect x="650" y="184" width="130" height="130" fill="#481e29" stroke="#090a0d" stroke-width="4"/>
<text x="684" y="262" fill="#ef6574" font-family="monospace" font-size="42" font-weight="700">AH</text>
<text x="812" y="194" fill="#8f90a0" font-family="monospace" font-size="18">SELECTED RUN</text>
<text x="812" y="238" fill="#f2f0dc" font-family="monospace" font-size="30" font-weight="700">Ashen Halls</text>
<text x="812" y="278" fill="#e5b95f" font-family="monospace" font-size="20">Part 1 • Normal</text>
<text x="650" y="358" fill="#aaa9b7" font-family="monospace" font-size="17">Fastest path for controller play. One list item is the exact run.</text>
<text x="650" y="394" fill="#aaa9b7" font-family="monospace" font-size="17">No separate dungeon, phase, or difficulty controls.</text>
<rect x="650" y="440" width="470" height="64" fill="#15161c" stroke="#33343d" stroke-width="3"/>
<text x="680" y="480" fill="#f2f0dc" font-family="monospace" font-size="18">Health 1.00x Damage 1.00x XP 1.0x</text>
<rect x="650" y="532" width="220" height="62" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
<text x="716" y="570" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Start</text>
<rect x="900" y="532" width="220" height="62" fill="#15161c" stroke="#41404a" stroke-width="4"/>
<text x="955" y="570" fill="#e5b95f" font-family="monospace" font-size="22">Loot</text>
<text x="78" y="684" fill="#aaa9b7" font-family="monospace" font-size="17">Idea C: Flat mission list. Most controller-friendly, least setup flexibility on one screen.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+72
View File
@@ -0,0 +1,72 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#111218"/>
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
<text x="958" y="88" fill="#e5b95f" font-family="monospace" font-size="18">B Back • A Select</text>
<text x="78" y="158" fill="#e5b95f" font-family="monospace" font-size="18">1. Pick Item Level</text>
<g>
<rect x="78" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="125" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">5</text>
<text x="108" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Initiate</text>
</g>
<g>
<rect x="268" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="310" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">10</text>
<text x="300" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Veteran</text>
</g>
<g>
<rect x="458" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="500" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">15</text>
<text x="488" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Champion</text>
</g>
<g>
<rect x="648" y="178" width="170" height="72" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
<text x="690" y="222" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">20</text>
<text x="690" y="240" fill="#e5b95f" font-family="monospace" font-size="12">Mythic</text>
</g>
<g opacity="0.65">
<rect x="838" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="880" y="222" fill="#777988" font-family="monospace" font-size="22">25</text>
<text x="864" y="240" fill="#777988" font-family="monospace" font-size="12">Level 20</text>
</g>
<text x="78" y="304" fill="#e5b95f" font-family="monospace" font-size="18">2. Pick Run</text>
<g>
<rect x="78" y="324" width="335" height="154" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
<rect x="96" y="346" width="64" height="64" fill="#481e29" stroke="#090a0d" stroke-width="3"/>
<text x="114" y="387" fill="#ef6574" font-family="monospace" font-size="24" font-weight="700">AH</text>
<text x="178" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls</text>
<text x="178" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
<text x="96" y="446" fill="#e5b95f" font-family="monospace" font-size="16">Part 1 unlocked</text>
</g>
<g>
<rect x="436" y="324" width="335" height="154" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<rect x="454" y="346" width="64" height="64" fill="#2a263f" stroke="#090a0d" stroke-width="3"/>
<text x="472" y="387" fill="#8ca9ff" font-family="monospace" font-size="24" font-weight="700">SC</text>
<text x="536" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt</text>
<text x="536" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
<text x="454" y="446" fill="#aaa9b7" font-family="monospace" font-size="16">Part 1 unlocked</text>
</g>
<g>
<rect x="794" y="324" width="335" height="154" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<rect x="812" y="346" width="64" height="64" fill="#223226" stroke="#090a0d" stroke-width="3"/>
<text x="830" y="387" fill="#70d990" font-family="monospace" font-size="24" font-weight="700">GM</text>
<text x="894" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Grove Maw</text>
<text x="894" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
<text x="812" y="446" fill="#777988" font-family="monospace" font-size="16">Locked dungeon</text>
</g>
<text x="78" y="532" fill="#e5b95f" font-family="monospace" font-size="18">3. Pick Part</text>
<rect x="78" y="552" width="250" height="64" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
<text x="124" y="592" fill="#f2f0dc" font-family="monospace" font-size="20">Part 1</text>
<rect x="352" y="552" width="250" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="398" y="592" fill="#aaa9b7" font-family="monospace" font-size="20">Part 2</text>
<rect x="626" y="552" width="250" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
<text x="672" y="592" fill="#aaa9b7" font-family="monospace" font-size="20">Part 3</text>
<rect x="926" y="552" width="226" height="64" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
<text x="991" y="592" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Start</text>
<text x="78" y="662" fill="#aaa9b7" font-family="monospace" font-size="17">Idea D: submenu flow. Pick item level first, then only compatible dungeon cards appear.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

+2
View File
@@ -8,8 +8,10 @@
"dev": "vite", "dev": "vite",
"build": "npm run offline:export && npx tsc -b --noEmit && npx vite build && node scripts/generate-service-worker.mjs", "build": "npm run offline:export && npx tsc -b --noEmit && npx vite build && node scripts/generate-service-worker.mjs",
"android:sync": "npm run build && cap sync android", "android:sync": "npm run build && cap sync android",
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:sync",
"android:open": "cap open android", "android:open": "cap open android",
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug", "android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
"accounts:ip": "node scripts/manage-ip-allowance.mjs", "accounts:ip": "node scripts/manage-ip-allowance.mjs",
"db:backup": "node scripts/backup-db.mjs", "db:backup": "node scripts/backup-db.mjs",
"db:init": "node scripts/init-db.mjs", "db:init": "node scripts/init-db.mjs",
+18 -10
View File
@@ -363,13 +363,6 @@ function initializeCharacter(database, accountId, characterName, classId) {
;[...starterSpells, null].slice(0, 6).forEach((spellId, index) => { ;[...starterSpells, null].slice(0, 6).forEach((spellId, index) => {
insertSlot.run(characterId, index + 1, spellId) insertSlot.run(characterId, index + 1, spellId)
}) })
const insertItem = database.prepare(`
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, 1, ?)
`)
for (let itemId = 100; itemId <= 107; itemId += 1) {
insertItem.run(characterId, itemId, itemId === 107 ? 0 : 1)
}
return characterId return characterId
} }
@@ -1692,11 +1685,24 @@ function craftItem(database, characterId, recipeId) {
crafting_recipes.item_id AS itemId, crafting_recipes.item_id AS itemId,
crafting_recipes.difficulty_id AS difficultyId, crafting_recipes.difficulty_id AS difficultyId,
crafting_recipes.source_dungeon_id AS sourceDungeonId, crafting_recipes.source_dungeon_id AS sourceDungeonId,
crafting_recipes.source_encounter_id AS sourceEncounterId crafting_recipes.source_encounter_id AS sourceEncounterId,
items.slot,
items.item_level AS itemLevel
FROM crafting_recipes FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id
WHERE crafting_recipes.id = ? WHERE crafting_recipes.id = ?
`).get(recipeId) `).get(recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.') if (!recipe) throw new Error('That crafting recipe does not exist.')
const lowerTierRecipe = database.prepare(`
SELECT crafting_recipes.id
FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id
WHERE crafting_recipes.source_encounter_id = ?
AND items.slot = ?
AND items.item_level < ?
LIMIT 1
`).get(recipe.sourceEncounterId, recipe.slot, recipe.itemLevel)
if (lowerTierRecipe) throw new Error('Upgrade the previous item tier instead.')
const components = database.prepare(` const components = database.prepare(`
SELECT SELECT
@@ -1777,8 +1783,10 @@ function upgradeItem(database, characterId, itemId) {
JOIN items ON items.id = crafting_recipes.item_id JOIN items ON items.id = crafting_recipes.item_id
WHERE crafting_recipes.source_encounter_id = ? WHERE crafting_recipes.source_encounter_id = ?
AND items.slot = ? AND items.slot = ?
AND items.item_level = ? AND items.item_level > ?
`).get(currentRecipe.sourceEncounterId, item.slot, item.itemLevel + 5) ORDER BY items.item_level
LIMIT 1
`).get(currentRecipe.sourceEncounterId, item.slot, item.itemLevel)
if (!targetRecipe) throw new Error('No upgrade is available for this item.') if (!targetRecipe) throw new Error('No upgrade is available for this item.')
const components = database.prepare(` const components = database.prepare(`
+2309 -8
View File
File diff suppressed because it is too large Load Diff
+192 -84
View File
@@ -131,6 +131,13 @@ function App() {
}) })
}, [screen]) }, [screen])
useEffect(() => {
if (!authChecked || !account || !profile || screen === 'combat') return
window.requestAnimationFrame(() => {
focusFirstControl()
})
}, [account, authChecked, profile, screen])
useEffect(() => { useEffect(() => {
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId)) window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
}, [selectedDifficultyId]) }, [selectedDifficultyId])
@@ -148,6 +155,9 @@ function App() {
setScreen('menu') setScreen('menu')
setError('') setError('')
setServerMessage('') setServerMessage('')
window.requestAnimationFrame(() => {
focusFirstControl()
})
} }
async function signOut() { async function signOut() {
@@ -272,10 +282,45 @@ function App() {
?? dungeonOptions[0]! ?? dungeonOptions[0]!
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId) const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
?? raidOptions[0] ?? raidOptions[0]
const activity = screen === 'raids' && raid ? raid : dungeon
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
const startPveRoguelike = () => {
const baseDungeon = dungeonOptions[0]
const baseRaid = raidOptions[0]
if (roguelikeKind === 'raid') {
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
} else {
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
}
setSelectedPart(1)
setScreen('combat')
}
const tierOptions = activityOptions
.flatMap((option) => option.difficulties)
.filter((difficulty, index, all) => (
all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index
))
.sort((a, b) => a.droppedItemLevel - b.droppedItemLevel)
const savedDifficulty = profile.dungeons
.flatMap((option) => option.difficulties)
.find((candidate) => candidate.id === selectedDifficultyId)
const selectedTier = tierOptions.find((candidate) => (
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
&& profile.character.level >= candidate.unlockLevel
))
?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel)
?? tierOptions[0]
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
const tierActivityOptions = activityOptions.filter((option) =>
option.difficulties.some((difficulty) => difficulty.droppedItemLevel === selectedTierItemLevel),
)
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
const activity = tierActivityOptions.find((candidate) => candidate.id === selectedActivityId)
?? tierActivityOptions[0]
?? (screen === 'raids' && raid ? raid : dungeon)
const selectedDifficulty = activity.difficulties.find( const selectedDifficulty = activity.difficulties.find(
(candidate) => candidate.id === selectedDifficultyId, (candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
) ?? activity.difficulties[0] ) ?? activity.difficulties[0]
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
const completedSections = activity.contentType === 'raid' const completedSections = activity.contentType === 'raid'
@@ -296,7 +341,7 @@ function App() {
: a.sequence - b.sequence) : a.sequence - b.sequence)
return ( return (
<main className="game-shell"> <main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''}`}>
<header className="topbar app-header"> <header className="topbar app-header">
<button <button
className="brand-button" className="brand-button"
@@ -393,6 +438,28 @@ function App() {
</div> </div>
{roguelikeVariant === 'pve' && ( {roguelikeVariant === 'pve' && (
<> <>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Run Type</p>
<h2>PvE Roguelike</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''}`}
onClick={() => setRoguelikeKind('dungeon')}
type="button"
>
Dungeon
</button>
<button
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''}`}
onClick={() => setRoguelikeKind('raid')}
type="button"
>
Raid
</button>
</div>
</div>
<div className="roguelike-option-panel"> <div className="roguelike-option-panel">
<div> <div>
<p className="eyebrow">Upgrade Timing</p> <p className="eyebrow">Upgrade Timing</p>
@@ -437,38 +504,22 @@ function App() {
</button> </button>
</div> </div>
</div> </div>
<div className="roguelike-mode-grid"> <div className="menu-card pvp-queue-panel">
<span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
<div>
<strong>{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'}</strong>
<small>
{roguelikeKind === 'raid'
? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
: 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'}
</small>
</div>
<button <button
className="menu-card" className="text-button"
onClick={() => { onClick={startPveRoguelike}
const baseDungeon = dungeonOptions[0]
setRoguelikeKind('dungeon')
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
setSelectedPart(1)
setScreen('combat')
}}
type="button" type="button"
> >
<span>D</span> Start Run
<strong>Dungeon Roguelike</strong>
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small>
</button>
<button
className="menu-card"
onClick={() => {
const baseRaid = raidOptions[0]
setRoguelikeKind('raid')
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
setSelectedPart(1)
setScreen('combat')
}}
type="button"
>
<span>R</span>
<strong>Raid Roguelike</strong>
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small>
</button> </button>
</div> </div>
</> </>
@@ -554,50 +605,128 @@ function App() {
)} )}
{(screen === 'dungeons' || screen === 'raids') && ( {(screen === 'dungeons' || screen === 'raids') && (
<section className="content-screen"> <section className="content-screen dungeon-run-screen">
<ScreenHeading <ScreenHeading
eyebrow="Adventure" eyebrow="Adventure"
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'} title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
onBack={() => setScreen('menu')} onBack={() => setScreen('menu')}
/> />
<article className="dungeon-card"> <div className="dungeon-run-board">
<div className="dungeon-run-main">
<article className="run-summary-card dungeon-focus-card">
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}> <div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(activity.name)} {activityInitials(activity.name)}
</div> </div>
<div> <div className="run-summary-copy">
<p className="eyebrow">{activity.locationName}</p> <p className="eyebrow">Selected Run</p>
<h2>{activity.name}</h2> <h2>{activity.name}</h2>
<p>{activity.description}</p> <p>{activity.description}</p>
<div className="tag-row"> <div className="tag-row">
<span>Level {activity.recommendedLevel}</span> <span>Level {activity.recommendedLevel}</span>
<span>{activity.partySize} Players</span> <span>{activity.partySize} Players</span>
<span>{selectedDifficulty.name}</span> <span>{selectedDifficulty.name}</span>
<span>Component Level {selectedDifficulty.droppedItemLevel}</span> <span>iLvl {selectedDifficulty.droppedItemLevel}</span>
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span> <span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
</div> </div>
</div> </div>
{activityOptions.length > 1 && ( </article>
<label className="activity-select">
<span>{activity.contentType === 'raid' ? 'Raid' : 'Dungeon'}</span> <section className="run-setup-panel dungeon-choice-panel">
<select <div className="run-setup-heading">
value={activity.id} <div>
onChange={(event) => { <p className="eyebrow">Pick Run</p>
const nextActivityId = Number(event.target.value) <h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
const nextActivity = activityOptions.find((candidate) => candidate.id === nextActivityId) </div>
if (screen === 'raids') setSelectedRaidId(nextActivityId) <small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
else setSelectedDungeonId(nextActivityId) </div>
if (nextActivity?.difficulties[0]) { <div className="activity-card-grid dungeon-choice-grid">
setSelectedDifficultyId(nextActivity.difficulties[0].id) {tierActivityOptions.map((candidate) => {
const difficulty = candidate.difficulties.find(
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
) ?? candidate.difficulties[0]
const locked = profile.character.level < difficulty.unlockLevel
const selected = candidate.id === activity.id
return (
<button
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
disabled={locked}
key={candidate.id}
onClick={() => {
if (screen === 'raids') setSelectedRaidId(candidate.id)
else setSelectedDungeonId(candidate.id)
setSelectedDifficultyId(difficulty.id)
}}
type="button"
>
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(candidate.name)}
</span>
<strong>{candidate.name}</strong>
<small>{candidate.locationName}</small>
<i>
Level {candidate.recommendedLevel} | {candidate.partySize} Players
</i>
</button>
)
})}
</div>
</section>
</div>
<aside className="dungeon-setup-rail">
<section className="run-setup-panel tier-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Item Level</p>
<h2>Tier</h2>
</div>
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
</div>
<div className="tier-grid">
{tierOptions.map((difficulty) => {
const locked = profile.character.level < difficulty.unlockLevel
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
return (
<button
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
disabled={locked}
key={difficulty.id}
onClick={() => {
const nextActivity = activity.difficulties.some(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
? activity
: activityOptions.find((option) =>
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
)
if (nextActivity) {
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
else setSelectedDungeonId(nextActivity.id)
const nextDifficulty = nextActivity.difficulties.find(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
} }
}} }}
type="button"
> >
{activityOptions.map((candidate) => ( <strong>iLvl {difficulty.droppedItemLevel}</strong>
<option key={candidate.id} value={candidate.id}>{candidate.name}</option> <span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
))} </button>
</select> )
</label> })}
)} </div>
<div className="part-buttons"> </section>
<section className="run-setup-panel part-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Start</p>
<h2>{sectionName}</h2>
</div>
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : 'Choose a section to launch.'}</small>
</div>
<div className="part-picker">
{parts.map((p) => ( {parts.map((p) => (
<button <button
key={p.part} key={p.part}
@@ -606,6 +735,7 @@ function App() {
onClick={() => { onClick={() => {
setSelectedPart(p.part) setSelectedPart(p.part)
setCombatContentId(activity.id) setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat') setScreen('combat')
}} }}
type="button" type="button"
@@ -614,34 +744,9 @@ function App() {
</button> </button>
))} ))}
</div> </div>
</article> </section>
<div className="difficulty-section compact-difficulty-section"> <div className="difficulty-section compact-difficulty-section">
<div className="difficulty-select-row">
<div>
<p className="eyebrow">Challenge Tier</p>
<h2>Difficulty</h2>
</div>
<label>
<span>Select</span>
<select
value={selectedDifficulty.id}
onChange={(event) => setSelectedDifficultyId(Number(event.target.value))}
>
{activity.difficulties.map((difficulty, index) => (
<option
disabled={profile.character.level < difficulty.unlockLevel}
key={difficulty.id}
value={difficulty.id}
>
{index + 1}. {difficulty.name}
{profile.character.level < difficulty.unlockLevel
? ` - Level ${difficulty.unlockLevel}`
: ''}
</option>
))}
</select>
</label>
</div>
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}> <div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
<div> <div>
<strong>{selectedDifficulty.name}</strong> <strong>{selectedDifficulty.name}</strong>
@@ -651,10 +756,11 @@ function App() {
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div> <div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div> <div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div> <div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
<div><dt>Components</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div> <div><dt>Loot</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
</dl> </dl>
</div> </div>
</div> </div>
<div className="loot-preview-section"> <div className="loot-preview-section">
<div className="equipment-heading toggle-heading"> <div className="equipment-heading toggle-heading">
<div> <div>
@@ -803,6 +909,8 @@ function App() {
)} )}
</div> </div>
)} )}
</aside>
</div>
</section> </section>
)} )}
+269 -135
View File
@@ -10,6 +10,10 @@ import {
import { import {
INITIAL_PARTY, INITIAL_PARTY,
RAID_PARTY, RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
groupHealTargets,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry, type CombatLogEntry,
type PartyMember, type PartyMember,
type Spell, type Spell,
@@ -73,6 +77,16 @@ type FloatingCombatText = {
value: number value: number
} }
type SinglePlayerCombatState = {
party: PartyMember[]
resource: number
enemyHealth: number
cooldowns: Record<string, number>
elapsedTicks: number
castsTowardFree: number
freeCastReady: boolean
}
const ROGUELIKE_MECHANICS: RoguelikeMechanic[] = [ const ROGUELIKE_MECHANICS: RoguelikeMechanic[] = [
'party-pulse', 'party-pulse',
'searing-mark', 'searing-mark',
@@ -340,13 +354,18 @@ export function CombatScreen({
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part' const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon' const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
const initialEncounterIndex = (startPart - 1) * 3 const initialEncounterIndex = (startPart - 1) * 3
const [party, setParty] = useState<PartyMember[]>(partyTemplate) const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
party: partyTemplate,
resource: maxResource,
enemyHealth: encounters[initialEncounterIndex].maxHealth,
cooldowns: {},
elapsedTicks: 0,
castsTowardFree: 0,
freeCastReady: false,
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
const [selectedId, setSelectedId] = useState(partyTemplate[0].id) const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const [resource, setResource] = useState(maxResource)
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex) const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
const [enemyHealth, setEnemyHealth] = useState(encounters[initialEncounterIndex].maxHealth)
const [cooldowns, setCooldowns] = useState<Record<string, number>>({})
const [, setElapsedTicks] = useState(0)
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing') const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0) const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
@@ -360,8 +379,6 @@ export function CombatScreen({
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([]) const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([]) const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([]) const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
const [, setCastsTowardFree] = useState(0)
const [freeCastReady, setFreeCastReady] = useState(false)
const rewardClaimedRef = useRef(false) const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false) const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<number>()) const rolledEncounterIdsRef = useRef(new Set<number>())
@@ -371,9 +388,14 @@ export function CombatScreen({
const partStartTimesRef = useRef<Record<number, number>>({}) const partStartTimesRef = useRef<Record<number, number>>({})
const nextLogId = useRef(2) const nextLogId = useRef(2)
const nextFloatingTextId = useRef(1) const nextFloatingTextId = useRef(1)
const partyRef = useRef(partyTemplate) const combatRef = useRef(initialCombatState)
const enemyHealthRef = useRef(encounters[initialEncounterIndex].maxHealth) const selectedIdRef = useRef(partyTemplate[0].id)
const elapsedTicksRef = useRef(0) const runCombatTickRef = useRef<() => void>(() => {})
const combatClockActiveRef = useRef(false)
const lastCombatTickAtRef = useRef(performance.now())
const statusRef = useRef(status)
const pausedRef = useRef(paused)
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
const encounter = encounters[encounterIndex] const encounter = encounters[encounterIndex]
const currentPart = getCurrentPart(encounterIndex) const currentPart = getCurrentPart(encounterIndex)
const firstEncounterIndex = (startPart - 1) * 3 const firstEncounterIndex = (startPart - 1) * 3
@@ -402,6 +424,9 @@ export function CombatScreen({
enabled: dualScreenEnabled, enabled: dualScreenEnabled,
} = useDualScreen() } = useDualScreen()
statusRef.current = status
pausedRef.current = paused
useEffect(() => { useEffect(() => {
const now = Date.now() const now = Date.now()
runStartedAtRef.current = now runStartedAtRef.current = now
@@ -415,6 +440,35 @@ export function CombatScreen({
}) })
}, [paused]) }, [paused])
const setCombat = useCallback((
nextState: SinglePlayerCombatState | ((current: SinglePlayerCombatState) => SinglePlayerCombatState),
) => {
const next = typeof nextState === 'function'
? nextState(combatRef.current)
: nextState
combatRef.current = next
setSelectedId(selectedIdRef.current)
setCombatState(next)
}, [])
const syncSelectedTargetDom = useCallback((id: string) => {
document.querySelectorAll<HTMLButtonElement>('[data-party-member-id]').forEach((button) => {
const selected = button.dataset.partyMemberId === id
button.classList.toggle('selected', selected)
button.setAttribute('aria-pressed', String(selected))
})
}, [])
const setSelectedTargetId = useCallback((id: string) => {
if (selectedIdRef.current === id) return
selectedIdRef.current = id
syncSelectedTargetDom(id)
}, [syncSelectedTargetDom])
useEffect(() => {
syncSelectedTargetDom(selectedIdRef.current)
}, [combatState, syncSelectedTargetDom])
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => { const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
const entry = { id: nextLogId.current++, text, tone } const entry = { id: nextLogId.current++, text, tone }
setLog((current) => [entry, ...current].slice(0, 60)) setLog((current) => [entry, ...current].slice(0, 60))
@@ -462,18 +516,19 @@ export function CombatScreen({
: [] : []
const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters
const freshParty = partyTemplate.map((member) => ({ ...member })) const freshParty = partyTemplate.map((member) => ({ ...member }))
partyRef.current = freshParty setCombat({
enemyHealthRef.current = nextEncounters[initialEncounterIndex].maxHealth party: freshParty,
resource: maxResource,
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth,
cooldowns: {},
elapsedTicks: 0,
castsTowardFree: 0,
freeCastReady: false,
})
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters) if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
setRoguelikeStage(1) setRoguelikeStage(1)
setParty(freshParty) setSelectedTargetId(partyTemplate[0].id)
setSelectedId(partyTemplate[0].id)
setResource(maxResource)
setEncounterIndex(initialEncounterIndex) setEncounterIndex(initialEncounterIndex)
setEnemyHealth(nextEncounters[initialEncounterIndex].maxHealth)
setCooldowns({})
elapsedTicksRef.current = 0
setElapsedTicks(0)
setStatus('playing') setStatus('playing')
setPaused(false) setPaused(false)
setTargetGroup(0) setTargetGroup(0)
@@ -484,8 +539,6 @@ export function CombatScreen({
setFloatingTexts([]) setFloatingTexts([])
setRoguelikeUpgrades([]) setRoguelikeUpgrades([])
setUpgradeChoices([]) setUpgradeChoices([])
setCastsTowardFree(0)
setFreeCastReady(false)
rewardClaimedRef.current = false rewardClaimedRef.current = false
profileRefreshedRef.current = false profileRefreshedRef.current = false
rolledEncounterIdsRef.current = new Set() rolledEncounterIdsRef.current = new Set()
@@ -494,36 +547,43 @@ export function CombatScreen({
runStartedAtRef.current = Date.now() runStartedAtRef.current = Date.now()
partStartTimesRef.current = { [startPart]: runStartedAtRef.current } partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }]) setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, startPart, staticEncounters]) }, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
const castSpell = useCallback( const castSpell = useCallback(
(spell: Spell) => { (spell: Spell) => {
const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, freeCastReady) const current = combatRef.current
if (status !== 'playing' || cooldowns[spell.id] > 0 || resource < effectiveCost) return const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, current.freeCastReady)
const healer = partyRef.current.find((member) => member.id === 'mira') if (status !== 'playing' || current.cooldowns[spell.id] > 0 || current.resource < effectiveCost) return
const healer = current.party.find((member) => member.id === 'mira')
if (!healer || healer.health <= 0) return if (!healer || healer.health <= 0) return
const selected = partyRef.current.find((member) => member.id === selectedId) const targetId = selectedIdRef.current
const selected = current.party.find((member) => member.id === targetId)
if (!selected || selected.health <= 0) return if (!selected || selected.health <= 0) return
const extraTarget = (blockedIds: string[]) => partyRef.current const extraTarget = (blockedIds: string[]) => current.party
.filter((member) => member.health > 0 && !blockedIds.includes(member.id)) .filter((member) => member.health > 0 && !blockedIds.includes(member.id))
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
const directTargets = new Set([selectedId]) const directTargets = new Set([targetId])
const hotTargets = new Set<string>() const hotTargets = new Set<string>()
const shieldTargets = new Set<string>() const shieldTargets = new Set<string>()
if (spell.kind === 'hot') hotTargets.add(selectedId) const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
if (spell.kind === 'shield') shieldTargets.add(selectedId) const groupTargets = new Set(
spell.kind === 'group'
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
: [],
)
if (spell.kind === 'hot') hotTargets.add(targetId)
if (spell.kind === 'shield') shieldTargets.add(targetId)
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) { if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
const extra = extraTarget([selectedId]) const extra = extraTarget([targetId])
if (extra) directTargets.add(extra.id) if (extra) directTargets.add(extra.id)
} }
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) { if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) {
const extra = extraTarget([selectedId]) const extra = extraTarget([targetId])
if (extra) hotTargets.add(extra.id) if (extra) hotTargets.add(extra.id)
} }
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) { if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
hotTargets.add(selectedId) hotTargets.add(targetId)
} }
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
for (let index = 0; index < extraTargets; index += 1) { for (let index = 0; index < extraTargets; index += 1) {
if (spell.kind === 'group') break if (spell.kind === 'group') break
if (spell.kind === 'hot') { if (spell.kind === 'hot') {
@@ -540,30 +600,10 @@ export function CombatScreen({
if (extra) directTargets.add(extra.id) if (extra) directTargets.add(extra.id)
} }
setResource((value) => value - effectiveCost) const nextParty = current.party.map((member) => {
resourceSpentRef.current += effectiveCost
setCooldowns((current) => ({
...current,
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
}))
if (upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free') > 0) {
if (freeCastReady) {
setFreeCastReady(false)
setCastsTowardFree(0)
} else {
setCastsTowardFree((current) => {
const next = current + 1
if (next >= 5) {
setFreeCastReady(true)
return 0
}
return next
})
}
}
const nextParty = partyRef.current.map((member) => {
if (member.health <= 0) return member if (member.health <= 0) return member
if (spell.kind === 'group') { if (spell.kind === 'group') {
if (!groupTargets.has(member.id)) return member
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost'))) const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
const nextHealth = healMember(member, power) const nextHealth = healMember(member, power)
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health)) addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
@@ -595,11 +635,35 @@ export function CombatScreen({
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
} }
}) })
partyRef.current = nextParty const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
setParty(nextParty) const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady
? false
: current.freeCastReady
const nextCastsTowardFree = freeCastStacks > 0
? current.freeCastReady
? 0
: current.castsTowardFree + 1 >= 5
? 0
: current.castsTowardFree + 1
: current.castsTowardFree
const gainedFreeCast = freeCastStacks > 0
&& !current.freeCastReady
&& current.castsTowardFree + 1 >= 5
resourceSpentRef.current += effectiveCost
setCombat({
...current,
party: nextParty,
resource: current.resource - effectiveCost,
cooldowns: {
...current.cooldowns,
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
},
castsTowardFree: nextCastsTowardFree,
freeCastReady: gainedFreeCast || nextFreeCastReady,
})
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal') addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
}, },
[activeSetEffects, addFloatingHeal, addLog, cooldowns, freeCastReady, resource, roguelikeUpgrades, selectedId, status], [activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, status],
) )
const finishRun = useCallback( const finishRun = useCallback(
@@ -662,25 +726,25 @@ export function CombatScreen({
) )
const selectRelativeTarget = useCallback((direction: -1 | 1) => { const selectRelativeTarget = useCallback((direction: -1 | 1) => {
const living = partyRef.current.filter((member) => member.health > 0) const living = combatRef.current.party.filter((member) => member.health > 0)
if (living.length === 0) return if (living.length === 0) return
const currentIndex = living.findIndex((member) => member.id === selectedId) const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
const nextIndex = currentIndex < 0 const nextIndex = currentIndex < 0
? 0 ? 0
: (currentIndex + direction + living.length) % living.length : (currentIndex + direction + living.length) % living.length
setSelectedId(living[nextIndex].id) setSelectedTargetId(living[nextIndex].id)
}, [selectedId]) }, [setSelectedTargetId])
const selectDirectionalTarget = useCallback((action: InputAction) => { const selectDirectionalTarget = useCallback((action: InputAction) => {
const columns = dungeon.partySize >= 10 ? 6 : 3 const columns = dungeon.partySize >= 10 ? 6 : 3
const currentIndex = partyRef.current.findIndex((member) => member.id === selectedId) const currentIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
if (currentIndex < 0) { if (currentIndex < 0) {
setSelectedId(partyRef.current[0].id) setSelectedTargetId(combatRef.current.party[0].id)
return return
} }
const currentRow = Math.floor(currentIndex / columns) const currentRow = Math.floor(currentIndex / columns)
const currentColumn = currentIndex % columns const currentColumn = currentIndex % columns
const candidates = partyRef.current const candidates = combatRef.current.party
.map((member, index) => ({ .map((member, index) => ({
member, member,
index, index,
@@ -709,19 +773,20 @@ export function CombatScreen({
: Math.abs(b.column - currentColumn) : Math.abs(b.column - currentColumn)
return aPrimary - bPrimary || aSecondary - bSecondary return aPrimary - bPrimary || aSecondary - bSecondary
}) })
if (candidates[0]) setSelectedId(candidates[0].member.id) if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
}, [dungeon.partySize, selectedId]) }, [dungeon.partySize, setSelectedTargetId])
const selectDirectTarget = useCallback((slot: number) => { const selectDirectTarget = useCallback((slot: number) => {
const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0) const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0)
const member = partyRef.current[index] const member = combatRef.current.party[index]
if (member) setSelectedId(member.id) if (member) setSelectedTargetId(member.id)
}, [dungeon.partySize, targetGroup]) }, [dungeon.partySize, setSelectedTargetId, targetGroup])
const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => { const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
if (!roguelikeMode) return if (!roguelikeMode) return
const current = combatRef.current
const clearedBoss = encounters[encounterIndex]?.isBoss ?? false const clearedBoss = encounters[encounterIndex]?.isBoss ?? false
const recoveredParty = partyRef.current.map((member) => ({ const recoveredParty = current.party.map((member) => ({
...member, ...member,
health: member.health <= 0 health: member.health <= 0
? 0 ? 0
@@ -740,24 +805,24 @@ export function CombatScreen({
? nextSegment[0] ? nextSegment[0]
: encounters[encounterIndex + 1] : encounters[encounterIndex + 1]
if (!nextEncounter) return if (!nextEncounter) return
partyRef.current = recoveredParty
enemyHealthRef.current = nextEncounter.maxHealth
setRoguelikeUpgrades((current) => [...current, upgrade]) setRoguelikeUpgrades((current) => [...current, upgrade])
if (clearedBoss) { if (clearedBoss) {
setRoguelikeStage(nextStage) setRoguelikeStage(nextStage)
setRoguelikeEncounters((current) => [...current, ...nextSegment]) setRoguelikeEncounters((current) => [...current, ...nextSegment])
} }
setParty(recoveredParty)
setEncounterIndex((current) => current + 1) setEncounterIndex((current) => current + 1)
setEnemyHealth(nextEncounter.maxHealth) setCombat({
elapsedTicksRef.current = 0 ...current,
setElapsedTicks(0) party: recoveredParty,
setCooldowns({}) enemyHealth: nextEncounter.maxHealth,
setResource((value) => clamp(value + Math.round(maxResource * 0.25), 0, maxResource)) elapsedTicks: 0,
cooldowns: {},
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
})
setUpgradeChoices([]) setUpgradeChoices([])
setStatus('playing') setStatus('playing')
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system') addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage]) }, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
useGameAction((action, device) => { useGameAction((action, device) => {
if (action === 'pause' || (action === 'back' && device === 'pc')) { if (action === 'pause' || (action === 'back' && device === 'pc')) {
@@ -776,11 +841,11 @@ export function CombatScreen({
if (action === 'toggleTargetGroup') { if (action === 'toggleTargetGroup') {
if (dungeon.partySize <= 6) return if (dungeon.partySize <= 6) return
setTargetGroup((current) => { setTargetGroup((current) => {
const groupCount = Math.max(1, Math.ceil(partyRef.current.length / 6)) const groupCount = Math.max(1, Math.ceil(combatRef.current.party.length / 6))
const next = ((current + 1) % groupCount) as 0 | 1 | 2 const next = ((current + 1) % groupCount) as 0 | 1 | 2
const selectedIndex = partyRef.current.findIndex((member) => member.id === selectedId) const selectedIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6] const nextMember = combatRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
if (nextMember) setSelectedId(nextMember.id) if (nextMember) setSelectedTargetId(nextMember.id)
return next return next
}) })
return return
@@ -799,23 +864,18 @@ export function CombatScreen({
if (spell) castSpell(spell) if (spell) castSpell(spell)
}) })
useEffect(() => { const runCombatTick = useCallback(() => {
if (status !== 'playing' || paused) return const current = combatRef.current
const timer = window.setInterval(() => { const nextElapsedTicks = current.elapsedTicks + 1
const nextElapsedTicks = elapsedTicksRef.current + 1 const nextCooldowns = Object.fromEntries(
elapsedTicksRef.current = nextElapsedTicks Object.entries(current.cooldowns).map(([id, seconds]) => [
setElapsedTicks(nextElapsedTicks)
setResource((value) => clamp(value + 2.4, 0, maxResource))
setCooldowns((current) =>
Object.fromEntries(
Object.entries(current).map(([id, seconds]) => [
id, id,
Math.max(0, seconds - TICK_MS / 1000), Math.max(0, seconds - TICK_MS / 1000),
]), ]),
),
) )
let nextResource = clamp(current.resource + 2.4, 0, maxResource)
const living = partyRef.current.filter((member) => member.health > 0) const living = current.party.filter((member) => member.health > 0)
if (living.length === 0) { if (living.length === 0) {
if (isRoguelike) finishRoguelikeRun(encounterIndex) if (isRoguelike) finishRoguelikeRun(encounterIndex)
setStatus('lost') setStatus('lost')
@@ -847,16 +907,22 @@ export function CombatScreen({
if (appliesHealingReduction) addLog(`${primaryTarget.name} receives reduced healing.`, 'danger') if (appliesHealingReduction) addLog(`${primaryTarget.name} receives reduced healing.`, 'danger')
if (tankBuster) addLog(`${encounter.enemyName} crushes the tanks.`, 'danger') if (tankBuster) addLog(`${encounter.enemyName} crushes the tanks.`, 'danger')
if (resourceDrain) { if (resourceDrain) {
setResource((value) => clamp(value - 8, 0, maxResource)) nextResource = clamp(nextResource - 8, 0, maxResource)
addLog(`${encounter.enemyName} drains ${gameClass.resourceName}.`, 'danger') addLog(`${encounter.enemyName} drains ${gameClass.resourceName}.`, 'danger')
} }
const healerBeforeDamage = partyRef.current.find((member) => member.id === 'mira') const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
const nextParty = partyRef.current.map((member) => { const tankPressure = tankPressureTargets(current.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const nextParty = current.party.map((member) => {
if (member.health <= 0) return member if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounter.damage : 0 let damage = member.id === primaryTarget.id ? encounter.damage : 0
if (member.role === 'Tank') damage += encounter.tankDamage if (tankPressureIds.has(member.id)) {
if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier) damage += Math.round(encounter.tankDamage * tankPressure.multiplier)
}
if (tankBuster && tankPressureIds.has(member.id)) {
damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier)
}
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier) if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier) if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
@@ -891,8 +957,6 @@ export function CombatScreen({
} }
}) })
const healerAfterDamage = nextParty.find((member) => member.id === 'mira') const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
partyRef.current = nextParty
setParty(nextParty)
if ( if (
healerBeforeDamage healerBeforeDamage
@@ -904,16 +968,30 @@ export function CombatScreen({
} }
if (nextParty.every((member) => member.health <= 0)) { if (nextParty.every((member) => member.health <= 0)) {
setCombat({
...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: current.enemyHealth,
})
if (isRoguelike) finishRoguelikeRun(encounterIndex) if (isRoguelike) finishRoguelikeRun(encounterIndex)
setStatus('lost') setStatus('lost')
addLog('The party has fallen.', 'danger') addLog('The party has fallen.', 'danger')
return return
} }
const nextEnemyHealth = enemyHealthRef.current - encounter.partyDamage const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage)
if (nextEnemyHealth > 0) { if (nextEnemyHealth > 0) {
enemyHealthRef.current = nextEnemyHealth setCombat({
setEnemyHealth(nextEnemyHealth) ...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: nextEnemyHealth,
})
return return
} }
@@ -922,8 +1000,14 @@ export function CombatScreen({
} }
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) { if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
enemyHealthRef.current = 0 setCombat({
setEnemyHealth(0) ...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: 0,
})
setUpgradeChoices(chooseRandom(roguelikeUpgradeCatalog, 3)) setUpgradeChoices(chooseRandom(roguelikeUpgradeCatalog, 3))
setStatus('upgrade-choice') setStatus('upgrade-choice')
addLog(`${encounter.enemyName} defeated. Choose an upgrade.`, 'loot') addLog(`${encounter.enemyName} defeated. Choose an upgrade.`, 'loot')
@@ -931,16 +1015,28 @@ export function CombatScreen({
} }
if (isPartBoss && !isFinalBoss) { if (isPartBoss && !isFinalBoss) {
enemyHealthRef.current = 0 setCombat({
setEnemyHealth(0) ...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: 0,
})
setStatus('part-complete') setStatus('part-complete')
addLog(`${encounter.enemyName} is defeated.`, 'loot') addLog(`${encounter.enemyName} is defeated.`, 'loot')
return return
} }
if (encounterIndex === encounters.length - 1) { if (encounterIndex === encounters.length - 1) {
enemyHealthRef.current = 0 setCombat({
setEnemyHealth(0) ...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: 0,
})
finishRun(currentPart, startPart) finishRun(currentPart, startPart)
addLog(`${encounter.enemyName} is defeated. Rolling its loot table.`, 'loot') addLog(`${encounter.enemyName} is defeated. Rolling its loot table.`, 'loot')
return return
@@ -958,16 +1054,16 @@ export function CombatScreen({
maxHealthPenaltyTicks: undefined, maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined, healingReductionTicks: undefined,
})) }))
partyRef.current = recoveredParty
enemyHealthRef.current = nextEncounter.maxHealth
setParty(recoveredParty)
setEncounterIndex((value) => value + 1) setEncounterIndex((value) => value + 1)
setEnemyHealth(nextEncounter.maxHealth) setCombat({
elapsedTicksRef.current = 0 ...current,
setElapsedTicks(0) party: recoveredParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: 0,
enemyHealth: nextEncounter.maxHealth,
})
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system') addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
}, TICK_MS)
return () => window.clearInterval(timer)
}, [ }, [
addLog, addLog,
addFloatingHeal, addFloatingHeal,
@@ -987,12 +1083,45 @@ export function CombatScreen({
gameClass.resourceName, gameClass.resourceName,
requestLootRoll, requestLootRoll,
profile.character.name, profile.character.name,
setCombat,
startPart, startPart,
status,
currentPart, currentPart,
paused,
]) ])
useEffect(() => {
runCombatTickRef.current = runCombatTick
}, [runCombatTick])
useEffect(() => {
if (status === 'playing' && !paused) {
if (!combatClockActiveRef.current) {
lastCombatTickAtRef.current = performance.now()
combatClockActiveRef.current = true
}
return
}
combatClockActiveRef.current = false
}, [paused, status])
useEffect(() => {
const timer = window.setInterval(() => {
if (
!combatClockActiveRef.current
|| statusRef.current !== 'playing'
|| pausedRef.current
) return
const now = performance.now()
const dueTicks = Math.min(4, Math.floor((now - lastCombatTickAtRef.current) / TICK_MS))
if (dueTicks <= 0) return
lastCombatTickAtRef.current += dueTicks * TICK_MS
for (let index = 0; index < dueTicks; index += 1) {
if (statusRef.current !== 'playing' || pausedRef.current) return
runCombatTickRef.current()
}
}, 50)
return () => window.clearInterval(timer)
}, [])
useEffect(() => { useEffect(() => {
if ( if (
!reward !reward
@@ -1133,17 +1262,16 @@ export function CombatScreen({
{party.map((member) => ( {party.map((member) => (
<button <button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`} className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
data-party-member-id={member.id}
key={member.id} key={member.id}
onClick={() => setSelectedId(member.id)} onClick={() => setSelectedTargetId(member.id)}
aria-pressed={selectedId === member.id} aria-pressed={selectedId === member.id}
type="button" type="button"
> >
{selectedId === member.id && (
<span className="target-marker" aria-hidden="true"> <span className="target-marker" aria-hidden="true">
<i /> <i />
Target Target
</span> </span>
)}
<div className="member-header"> <div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span> <span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong> <strong>{member.name}</strong>
@@ -1219,7 +1347,7 @@ export function CombatScreen({
{dualScreenEnabled && ( {dualScreenEnabled && (
<DualScreenTopCombat <DualScreenTopCombat
state={dualScreenState} state={dualScreenState}
onSelectTarget={setSelectedId} onSelectTarget={setSelectedTargetId}
/> />
)} )}
@@ -1239,7 +1367,7 @@ export function CombatScreen({
{status === 'upgrade-choice' && ( {status === 'upgrade-choice' && (
<div className="result-screen"> <div className="result-screen">
<div> <div className="pvp-upgrade-dialog pve-upgrade-dialog">
<p className="eyebrow"> <p className="eyebrow">
{encounter.isBoss {encounter.isBoss
? `Roguelike Stage ${roguelikeStage} Complete` ? `Roguelike Stage ${roguelikeStage} Complete`
@@ -1247,6 +1375,9 @@ export function CombatScreen({
</p> </p>
<h2>Choose Upgrade</h2> <h2>Choose Upgrade</h2>
<p>Pick one upgrade before the next fight.</p> <p>Pick one upgrade before the next fight.</p>
<div className="pvp-choice-columns">
<div>
<strong>Run Buff</strong>
<div className="upgrade-choice-grid"> <div className="upgrade-choice-grid">
{upgradeChoices.map((upgrade) => ( {upgradeChoices.map((upgrade) => (
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button"> <button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
@@ -1255,6 +1386,8 @@ export function CombatScreen({
</button> </button>
))} ))}
</div> </div>
</div>
</div>
{roguelikeUpgrades.length > 0 && ( {roguelikeUpgrades.length > 0 && (
<p className="roguelike-upgrade-list"> <p className="roguelike-upgrade-list">
Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)} Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}
@@ -1391,19 +1524,20 @@ export function CombatScreen({
const nextIndex = encounterIndex + 1 const nextIndex = encounterIndex + 1
partStartTimesRef.current[currentPart + 1] = Date.now() partStartTimesRef.current[currentPart + 1] = Date.now()
const nextEncounter = encounters[nextIndex] const nextEncounter = encounters[nextIndex]
const recoveredParty = partyRef.current.map((member) => ({ const current = combatRef.current
const recoveredParty = current.party.map((member) => ({
...member, ...member,
health: clamp(member.health + 35, 0, member.maxHealth), health: clamp(member.health + 35, 0, member.maxHealth),
debuff: undefined, debuff: undefined,
debuffTicks: undefined, debuffTicks: undefined,
})) }))
partyRef.current = recoveredParty
enemyHealthRef.current = nextEncounter.maxHealth
setParty(recoveredParty)
setEncounterIndex(nextIndex) setEncounterIndex(nextIndex)
setEnemyHealth(nextEncounter.maxHealth) setCombat({
elapsedTicksRef.current = 0 ...current,
setElapsedTicks(0) party: recoveredParty,
enemyHealth: nextEncounter.maxHealth,
elapsedTicks: 0,
})
setStatus('playing') setStatus('playing')
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system') addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
}} }}
+41 -2
View File
@@ -4,6 +4,7 @@ import {
type CharacterProfile, type CharacterProfile,
type GameClass, type GameClass,
} from '../profile' } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { EquipmentScreen } from './EquipmentScreen' import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen' import { TalentScreen } from './TalentScreen'
@@ -14,7 +15,8 @@ type Props = {
} }
export function CustomizeScreen({ profile, onBack, onSaved }: Props) { export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class') const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
const { enabled: dualScreenEnabled } = useDualScreen()
const [classId, setClassId] = useState(profile.character.classId) const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots) const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0) const [selectedSlot, setSelectedSlot] = useState(0)
@@ -63,6 +65,29 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
) )
} }
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'class') return null
return {
mode: 'class',
title: 'Ability Library',
subtitle: gameClass.name,
summary: `Selected slot ${selectedSlot + 1}. ${message || 'Choose an ability for the active loadout.'}`,
items: gameClass.spells.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return {
glyph: locked ? 'L' : ability.glyph,
title: ability.name,
meta: locked ? `Level ${ability.unlockLevel}` : `${ability.cost} ${gameClass.resourceName}`,
detail: ability.description,
status: equipped ? 'Equipped' : locked ? 'Locked' : '',
}
}),
}
}, [activeTab, gameClass, message, profile.character.level, selectedSlot, slots])
useDualScreenWorkshopPublisher(classWorkshopState, dualScreenEnabled)
async function persistChanges() { async function persistChanges() {
saveScroll() saveScroll()
setSaving(true) setSaving(true)
@@ -80,7 +105,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
return ( return (
<section className="content-screen customize-screen"> <section className="content-screen customize-screen">
<div className="screen-heading"> <div className="screen-heading customize-heading">
<div> <div>
<p className="eyebrow">Character Workshop</p> <p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1> <h1>Customize Character</h1>
@@ -89,8 +114,10 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
</div> </div>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections"> <div className="customize-tabs" role="tablist" aria-label="Customize character sections">
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
{([ {([
{ key: 'equipment', label: 'Equipment' }, { key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' }, { key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' }, { key: 'class', label: 'Class' },
] as const).map((tab) => ( ] as const).map((tab) => (
@@ -110,6 +137,18 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
{activeTab === 'equipment' && ( {activeTab === 'equipment' && (
<EquipmentScreen <EquipmentScreen
embedded embedded
mode="equipment"
showModeTabs={false}
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'crafting' && (
<EquipmentScreen
embedded
mode="crafting"
showModeTabs={false}
profile={profile} profile={profile}
onUpdated={onSaved} onUpdated={onSaved}
/> />
+323 -119
View File
@@ -9,6 +9,7 @@ import {
type EquipmentSlot, type EquipmentSlot,
type Item, type Item,
} from '../profile' } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
const SLOT_LABELS: Record<EquipmentSlot, string> = { const SLOT_LABELS: Record<EquipmentSlot, string> = {
weapon: 'Weapon', weapon: 'Weapon',
@@ -24,16 +25,28 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
} }
const EQUIPMENT_LIST_PAGE_SIZE = 3 const EQUIPMENT_LIST_PAGE_SIZE = 3
const CRAFTING_LIST_PAGE_SIZE = 6 const CRAFTING_LIST_PAGE_SIZE = 3
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
.filter((slot) => slot !== 'component')
type Props = { type Props = {
profile: CharacterProfile profile: CharacterProfile
onBack?: () => void onBack?: () => void
onUpdated: (profile: CharacterProfile) => void onUpdated: (profile: CharacterProfile) => void
embedded?: boolean embedded?: boolean
mode?: 'equipment' | 'crafting'
showModeTabs?: boolean
} }
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) { export function EquipmentScreen({
profile,
onBack,
onUpdated,
embedded = false,
mode,
showModeTabs = true,
}: Props) {
const { enabled: dualScreenEnabled } = useDualScreen()
const totalItemCount = profile.inventory.reduce( const totalItemCount = profile.inventory.reduce(
(total, item) => total + item.quantity, (total, item) => total + item.quantity,
0, 0,
@@ -49,7 +62,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [crafting, setCrafting] = useState(false) const [crafting, setCrafting] = useState(false)
const [upgrading, setUpgrading] = useState(false) const [upgrading, setUpgrading] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false) const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment') const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
const [inventoryPage, setInventoryPage] = useState(0) const [inventoryPage, setInventoryPage] = useState(0)
const [recipePage, setRecipePage] = useState(0) const [recipePage, setRecipePage] = useState(0)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
@@ -61,15 +74,24 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
firstRecipe?.id ?? null, firstRecipe?.id ?? null,
) )
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId) const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
const selectedRecipeRequiresUpgrade = selectedRecipe
? profile.craftingRecipes.some((recipe) =>
recipe.sourceEncounterId === selectedRecipe.sourceEncounterId
&& recipe.item.slot === selectedRecipe.item.slot
&& recipe.item.itemLevel < selectedRecipe.item.itemLevel,
)
: false
const selectedItemRecipe = selectedItem const selectedItemRecipe = selectedItem
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id) ? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
: undefined : undefined
const upgradeRecipe = selectedItem && selectedItemRecipe const upgradeRecipe = selectedItem && selectedItemRecipe
? profile.craftingRecipes.find((recipe) => ? profile.craftingRecipes
.filter((recipe) =>
recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId
&& recipe.item.slot === selectedItem.slot && recipe.item.slot === selectedItem.slot
&& recipe.item.itemLevel === selectedItem.itemLevel + 5, && recipe.item.itemLevel > selectedItem.itemLevel,
) )
.sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0]
: undefined : undefined
const equippedBySlot = useMemo( const equippedBySlot = useMemo(
() => new Map( () => new Map(
@@ -117,6 +139,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
}, },
[profile.craftingRecipes, slotFilter, levelFilter], [profile.craftingRecipes, slotFilter, levelFilter],
) )
const readyRecipeCount = filteredRecipes.filter((recipe) => recipe.canCraft).length
const slotRecipeCounts = useMemo(
() => new Map(
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
slot,
profile.craftingRecipes.filter((recipe) => recipe.item.slot === slot).length,
]),
),
[profile.craftingRecipes],
)
const recipePageCount = Math.max( const recipePageCount = Math.max(
1, 1,
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE), Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
@@ -138,12 +170,26 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
setRecipePage((current) => Math.min(current, recipePageCount - 1)) setRecipePage((current) => Math.min(current, recipePageCount - 1))
}, [recipePageCount]) }, [recipePageCount])
useEffect(() => {
if (filteredRecipes.length === 0) {
setSelectedRecipeId(null)
return
}
if (!filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)) {
setSelectedRecipeId(filteredRecipes[0].id)
}
}, [filteredRecipes, selectedRecipeId])
useEffect(() => { useEffect(() => {
if (equipmentTab === 'crafting') { if (equipmentTab === 'crafting') {
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {}) loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
} }
}, [equipmentTab]) }, [equipmentTab])
useEffect(() => {
if (mode) setEquipmentTab(mode)
}, [mode])
function saveScroll() { function saveScroll() {
scrollRef.current = window.scrollY scrollRef.current = window.scrollY
} }
@@ -218,73 +264,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
} }
} }
const content = ( function renderEquipmentActions() {
if (!selectedItem) {
return <p>Select an item to inspect it.</p>
}
if (selectedItem.slot === 'component') {
return <p className="component-note">Used in crafting.</p>
}
return (
<> <>
{!embedded && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Loadout</p>
<h1>Equipment</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="gear-summary">
<div className="gear-character">
<span style={{ borderColor: profile.character.themeColor, color: profile.character.themeColor }}>
{profile.character.className[0]}
</span>
<div>
<p className="eyebrow">{profile.character.className}</p>
<h2>{profile.character.name}</h2>
</div>
</div>
<GearStat value={profile.gearStats.averageItemLevel.toFixed(1)} label="Average Item Level" />
<GearStat value={`+${profile.gearStats.healingPower}`} label="Healing Power" />
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div>
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
{equipmentTab === 'equipment' ? (
<>
<section className="item-comparison">
{selectedItem ? (
selectedItem.slot === 'component' ? (
<>
<ItemDetail title="Crafting Component" item={selectedItem} />
<div className="equip-action">
<p className="component-note">Used in crafting.</p>
</div>
</>
) : (
<>
<ItemDetail title={selectedItem.equipped ? 'Selected Equipment' : 'Inventory Item'} item={selectedItem} />
<div className="comparison-arrow">vs</div>
{comparisonItem && comparisonItem.id !== selectedItem.id ? (
<ItemDetail title="Currently Equipped" item={comparisonItem} />
) : (
<div className="item-detail empty-comparison">
<p className="eyebrow">Comparison</p>
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div>
)}
<div className="equip-action">
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} /> <ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button <button
className="primary-button" className="primary-button"
@@ -318,7 +306,166 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
: 'Break Down'} : 'Break Down'}
</button> </button>
)} )}
</>
)
}
const workshopState = useMemo<DualScreenWorkshopState>(() => {
if (equipmentTab === 'crafting') {
if (!selectedRecipe) {
return {
mode: 'crafting',
title: 'Craft Output',
subtitle: 'No recipe selected',
items: [],
}
}
return {
mode: 'crafting',
title: selectedRecipe.item.name,
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
summary: selectedRecipe.item.description,
items: [
{
glyph: selectedRecipe.item.glyph,
title: 'Craft Output',
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
},
...selectedRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Item Level ${component.item.itemLevel}`,
status: `${component.owned}/${component.quantity}`,
})),
],
}
}
if (!selectedItem) {
return {
mode: 'equipment',
title: 'Equipment Detail',
subtitle: 'No item selected',
items: [],
}
}
return {
mode: 'equipment',
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
summary: selectedItem.description,
items: selectedItem.slot === 'component'
? [{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `Owned: ${selectedItem.quantity}`,
status: 'Component',
}]
: [
{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
},
...(comparisonItem && comparisonItem.id !== selectedItem.id
? [{
glyph: comparisonItem.glyph,
title: comparisonItem.name,
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
status: 'Currently Equipped',
}]
: [{
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
status: 'Comparison',
}]),
...(upgradeRecipe
? [
{
glyph: upgradeRecipe.item.glyph,
title: `Upgrade to ${upgradeRecipe.item.name}`,
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
},
...upgradeRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Required for upgrade`,
status: `${component.owned}/${component.quantity}`,
})),
]
: []),
],
}
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
const content = (
<>
{!embedded && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Loadout</p>
<h1>Equipment</h1>
</div> </div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="gear-summary">
<div className="gear-character">
<span style={{ borderColor: profile.character.themeColor, color: profile.character.themeColor }}>
{profile.character.className[0]}
</span>
<div>
<p className="eyebrow">{profile.character.className}</p>
<h2>{profile.character.name}</h2>
</div>
</div>
<GearStat value={profile.gearStats.averageItemLevel.toFixed(1)} label="Average Item Level" />
<GearStat value={`+${profile.gearStats.healingPower}`} label="Healing Power" />
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div>
{showModeTabs && (
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
)}
{equipmentTab === 'equipment' ? (
<>
<section className="item-comparison">
{selectedItem ? (
selectedItem.slot === 'component' ? (
<>
<ItemDetail title="Crafting Component" item={selectedItem} />
</>
) : (
<>
<ItemDetail title={selectedItem.equipped ? 'Selected Equipment' : 'Inventory Item'} item={selectedItem} />
<div className="comparison-arrow">vs</div>
{comparisonItem && comparisonItem.id !== selectedItem.id ? (
<ItemDetail title="Currently Equipped" item={comparisonItem} />
) : (
<div className="item-detail empty-comparison">
<p className="eyebrow">Comparison</p>
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div>
)}
</> </>
) )
) : ( ) : (
@@ -326,6 +473,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
)} )}
</section> </section>
<section className="equipment-action-strip">
{renderEquipmentActions()}
</section>
<div className="equipment-layout"> <div className="equipment-layout">
<section className="equipped-panel"> <section className="equipped-panel">
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" /> <EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
@@ -421,42 +572,81 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<section className="crafting-panel"> <section className="crafting-panel">
<EquipmentHeading <EquipmentHeading
eyebrow="Crafting" eyebrow="Crafting"
title="Recipes" title="Workbench"
detail={`${filteredRecipes.filter((recipe) => recipe.canCraft).length} ready`} detail={`${readyRecipeCount} ready / ${filteredRecipes.length} shown`}
/> />
<div className="crafting-filter-bar">
<select
className="filter-select"
value={slotFilter}
onChange={(e) => {
setSlotFilter(e.target.value as EquipmentSlot | 'all')
setRecipePage(0)
}}
>
<option value="all">All Slots</option>
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
<option key={slot} value={slot}>{label}</option>
))}
</select>
<select
className="filter-select"
value={levelFilter ?? ''}
onChange={(e) => {
setLevelFilter(e.target.value === '' ? null : Number(e.target.value))
setRecipePage(0)
}}
>
<option value="">All Levels</option>
{availableLevels.map((level) => (
<option key={level} value={level}>Item Level {level}</option>
))}
</select>
</div>
{filteredRecipes.length === 0 && (
<p className="inventory-empty">No crafting recipes match filters.</p>
)}
{filteredRecipes.length > 0 && (
<div className="crafting-layout"> <div className="crafting-layout">
<aside className="crafting-filters">
<div>
<p className="eyebrow">Slot</p>
<div className="crafting-filter-grid">
<button
className={slotFilter === 'all' ? 'active' : ''}
onClick={() => {
setSlotFilter('all')
setRecipePage(0)
}}
type="button"
>
<strong>All</strong>
<span>{profile.craftingRecipes.length}</span>
</button>
{CRAFTING_FILTER_SLOTS.map((slot) => (
<button
className={slotFilter === slot ? 'active' : ''}
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
key={slot}
onClick={() => {
setSlotFilter(slot)
setRecipePage(0)
}}
type="button"
>
<strong>{SLOT_LABELS[slot]}</strong>
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
</button>
))}
</div>
</div>
<div>
<p className="eyebrow">Item Level</p>
<div className="crafting-level-row">
<button
className={levelFilter === null ? 'active' : ''}
onClick={() => {
setLevelFilter(null)
setRecipePage(0)
}}
type="button"
>
All
</button>
{availableLevels.map((level) => (
<button
className={levelFilter === level ? 'active' : ''}
key={level}
onClick={() => {
setLevelFilter(level)
setRecipePage(0)
}}
type="button"
>
{level}
</button>
))}
</div>
</div>
</aside>
<section className="crafting-list-panel">
<EquipmentHeading
eyebrow="Recipes"
title={slotFilter === 'all' ? 'Available' : SLOT_LABELS[slotFilter]}
detail={`Page ${recipePage + 1}/${recipePageCount}`}
/>
{filteredRecipes.length === 0 ? (
<p className="inventory-empty">No recipes match filters.</p>
) : (
<div className="crafting-list"> <div className="crafting-list">
{recipePageItems.map((recipe) => ( {recipePageItems.map((recipe) => (
<button <button
@@ -473,9 +663,13 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''} {recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
</small> </small>
</div> </div>
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i> <i className={recipe.canCraft ? 'ready' : 'missing'}>
{recipe.canCraft ? 'Ready' : 'Needs materials'}
</i>
</button> </button>
))} ))}
</div>
)}
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && ( {filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
<ListPager <ListPager
label={`Page ${recipePage + 1} / ${recipePageCount}`} label={`Page ${recipePage + 1} / ${recipePageCount}`}
@@ -485,10 +679,26 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
previousDisabled={recipePage <= 0} previousDisabled={recipePage <= 0}
/> />
)} )}
<div className="crafting-action-row">
<button
className="primary-button"
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
</button>
</div> </div>
{selectedRecipe && ( </section>
<section className="crafting-detail-panel">
{selectedRecipe ? (
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}> <div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} /> <ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
<div className="crafting-detail-heading">
<p className="eyebrow">Materials</p>
<span>{selectedRecipe.canCraft ? 'Ready' : 'Missing components'}</span>
</div>
<div className="crafting-components"> <div className="crafting-components">
{selectedRecipe.components.map((component) => ( {selectedRecipe.components.map((component) => (
<div <div
@@ -501,22 +711,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
</div> </div>
))} ))}
</div> </div>
<button
className="primary-button"
disabled={!selectedRecipe.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : 'Craft Item'}
</button>
</div> </div>
) : (
<p className="inventory-empty">Select a recipe.</p>
)} )}
</section>
</div> </div>
)}
</section> </section>
)} )}
{profile.setBonuses.length > 0 && ( {equipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
<section className="set-bonus-panel"> <section className="set-bonus-panel">
<div className="equipment-heading toggle-heading"> <div className="equipment-heading toggle-heading">
<div> <div>
@@ -552,11 +756,11 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
) )
if (embedded) { if (embedded) {
return <div className="equipment-screen embedded-screen">{content}</div> return <div className={`equipment-screen embedded-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
} }
return ( return (
<section className="content-screen equipment-screen"> <section className={`content-screen equipment-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
{content} {content}
</section> </section>
) )
+158 -33
View File
@@ -1,5 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { INITIAL_PARTY, RAID_PARTY, type CombatLogEntry, type PartyMember, type Spell } from '../game' import {
INITIAL_PARTY,
RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
groupHealTargets,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry,
type PartyMember,
type Spell,
} from '../game'
import { completeRoguelike, type DungeonReward } from '../profile' import { completeRoguelike, type DungeonReward } from '../profile'
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile' import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository' import type { GameMode } from '../gameRepository'
@@ -78,6 +88,17 @@ type FloatingCombatText = {
value: number value: number
} }
type PvpRunSummary = {
bossesKilled: number
experienceGained: number
previousLevel: number | null
newLevel: number | null
levelsGained: number
talentPointsGained: number
unlockedAbilities: DungeonReward['unlockedAbilities']
loot: Array<NonNullable<DungeonReward['bonusItem']>>
}
const BOSS_MECHANICS: BossMechanic[] = [ const BOSS_MECHANICS: BossMechanic[] = [
'party-pulse', 'party-pulse',
'searing-mark', 'searing-mark',
@@ -119,6 +140,19 @@ function formatEffectTime(ticks: number) {
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s` return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
} }
function createEmptyPvpRunSummary(): PvpRunSummary {
return {
bossesKilled: 0,
experienceGained: 0,
previousLevel: null,
newLevel: null,
levelsGained: 0,
talentPointsGained: 0,
unlockedAbilities: [],
loot: [],
}
}
function buffStacks<T extends string>(items: T[], id: T) { function buffStacks<T extends string>(items: T[], id: T) {
return items.filter((item) => item === id).length return items.filter((item) => item === id).length
} }
@@ -411,11 +445,13 @@ export function PvPRoguelikeScreen({
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource)) const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource)) const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
const [selectedId, setSelectedId] = useState(partyTemplate[0].id) const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const selectedIdRef = useRef(partyTemplate[0].id)
const [elapsedTicks, setElapsedTicks] = useState(0) const [elapsedTicks, setElapsedTicks] = useState(0)
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null) const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
const [queueMessage, setQueueMessage] = useState('') const [queueMessage, setQueueMessage] = useState('')
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }]) const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
const [reward, setReward] = useState<DungeonReward | null>(null) const [reward, setReward] = useState<DungeonReward | null>(null)
const [runSummary, setRunSummary] = useState<PvpRunSummary>(() => createEmptyPvpRunSummary())
const [rewardError, setRewardError] = useState('') const [rewardError, setRewardError] = useState('')
const [showEndLog, setShowEndLog] = useState(false) const [showEndLog, setShowEndLog] = useState(false)
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([]) const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
@@ -433,6 +469,8 @@ export function PvPRoguelikeScreen({
const bossRewardClaimedRef = useRef(new Set<number>()) const bossRewardClaimedRef = useRef(new Set<number>())
const cpuDefeatedRef = useRef(false) const cpuDefeatedRef = useRef(false)
const playerClearedEncounterRef = useRef(-1) const playerClearedEncounterRef = useRef(-1)
const queuedMatchRef = useRef(false)
const encounterPoolRef = useRef(encounterPool)
const playerRef = useRef(playerSide) const playerRef = useRef(playerSide)
const cpuRef = useRef(cpuSide) const cpuRef = useRef(cpuSide)
const encounter = encounters[encounterIndex] const encounter = encounters[encounterIndex]
@@ -459,6 +497,12 @@ export function PvPRoguelikeScreen({
const { const {
enabled: dualScreenEnabled, enabled: dualScreenEnabled,
} = useDualScreen() } = useDualScreen()
const setSelectedTargetId = useCallback((id: string) => {
selectedIdRef.current = id
setSelectedId(id)
}, [])
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => { const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60)) setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
}, []) }, [])
@@ -473,11 +517,16 @@ export function PvPRoguelikeScreen({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (queuedMatchRef.current) return
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType) const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
setCheckpointStage(loadedCheckpoint) setCheckpointStage(loadedCheckpoint)
setStartStage(loadedCheckpoint) setStartStage(loadedCheckpoint)
}, [contentType, profile.character.id]) }, [contentType, profile.character.id])
useEffect(() => {
encounterPoolRef.current = encounterPool
}, [encounterPool])
const awardBossReward = useCallback((encounterIndexValue: number) => { const awardBossReward = useCallback((encounterIndexValue: number) => {
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
bossRewardClaimedRef.current.add(encounterIndexValue) bossRewardClaimedRef.current.add(encounterIndexValue)
@@ -497,6 +546,20 @@ export function PvPRoguelikeScreen({
) )
.then((result) => { .then((result) => {
setReward(result) setReward(result)
setRunSummary((current) => {
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
return {
bossesKilled: current.bossesKilled + 1,
experienceGained: current.experienceGained + result.experienceGained,
previousLevel: current.previousLevel ?? result.previousLevel,
newLevel: result.newLevel,
levelsGained: current.levelsGained + result.levelsGained,
talentPointsGained: current.talentPointsGained + result.talentPointsGained,
unlockedAbilities: Array.from(unlockedById.values()),
loot: result.bonusItem ? [...current.loot, result.bonusItem] : current.loot,
}
})
onProfileUpdated(result.profile) onProfileUpdated(result.profile)
if (result.bonusItem) { if (result.bonusItem) {
addLog( addLog(
@@ -532,8 +595,9 @@ export function PvPRoguelikeScreen({
: null) : null)
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
useEffect(() => { const startMatch = useCallback((nextStartStage?: number) => {
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType) const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
const firstEncounter = firstSegment[0] const firstEncounter = firstSegment[0]
const basePlayer = starterSide(partyTemplate, maxResource) const basePlayer = starterSide(partyTemplate, maxResource)
const baseCpu = starterSide(cpuPartyTemplate, maxResource) const baseCpu = starterSide(cpuPartyTemplate, maxResource)
@@ -543,15 +607,18 @@ export function PvPRoguelikeScreen({
cpuRef.current = baseCpu cpuRef.current = baseCpu
nextLogId.current = 2 nextLogId.current = 2
playerClearedEncounterRef.current = -1 playerClearedEncounterRef.current = -1
queuedMatchRef.current = true
bossRewardClaimedRef.current = new Set() bossRewardClaimedRef.current = new Set()
setEncounters(firstSegment) setEncounters(firstSegment)
setEncounterIndex(0) setEncounterIndex(0)
setStage(startStage) setCheckpointStage(matchStartStage)
setStartStage(matchStartStage)
setStage(matchStartStage)
setElapsedTicks(0) setElapsedTicks(0)
setStatus('queueing') setStatus('queueing')
setPlayerSide(basePlayer) setPlayerSide(basePlayer)
setCpuSide(baseCpu) setCpuSide(baseCpu)
setSelectedId(partyTemplate[0].id) setSelectedTargetId(partyTemplate[0].id)
setPlayerBuffChoices([]) setPlayerBuffChoices([])
setPlayerDebuffChoices([]) setPlayerDebuffChoices([])
setSelectedBuff(null) setSelectedBuff(null)
@@ -560,6 +627,7 @@ export function PvPRoguelikeScreen({
setPaused(false) setPaused(false)
setTargetGroup(0) setTargetGroup(0)
setReward(null) setReward(null)
setRunSummary(createEmptyPvpRunSummary())
setRewardError('') setRewardError('')
setShowEndLog(false) setShowEndLog(false)
setFloatingTexts([]) setFloatingTexts([])
@@ -569,26 +637,28 @@ export function PvPRoguelikeScreen({
cpuDefeatedRef.current = false cpuDefeatedRef.current = false
if (gameMode === 'offline') { if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty() const randomCpu = randomCpuDifficulty()
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`) setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
setCpuDifficulty(randomCpu) setCpuDifficulty(randomCpu)
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`, tone: 'system' }]) setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
setStatus('playing') setStatus('playing')
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system') addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
}, 500) }, 500)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
} }
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`) setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }]) setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const randomCpu = randomCpuDifficulty() const randomCpu = randomCpuDifficulty()
setCpuDifficulty(randomCpu) setCpuDifficulty(randomCpu)
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`) setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
setStatus('playing') setStatus('playing')
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system') addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
}, 1400) }, 1400)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage]) }, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
useEffect(() => startMatch(), [startMatch])
const applySpell = useCallback(( const applySpell = useCallback((
current: SideState, current: SideState,
@@ -611,6 +681,11 @@ export function PvPRoguelikeScreen({
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : []) const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : []) const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId) const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId)
const groupTargets = new Set(
spell.kind === 'group'
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
: [],
)
for (let index = 0; index < extraTargets; index += 1) { for (let index = 0; index < extraTargets; index += 1) {
if (spell.kind === 'group') break if (spell.kind === 'group') break
if (spell.kind === 'hot') { if (spell.kind === 'hot') {
@@ -629,6 +704,7 @@ export function PvPRoguelikeScreen({
const nextParty = current.party.map((member) => { const nextParty = current.party.map((member) => {
if (member.health <= 0) return member if (member.health <= 0) return member
if (spell.kind === 'group') { if (spell.kind === 'group') {
if (!groupTargets.has(member.id)) return member
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost'))) const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
const nextHealth = healMember(member, groupPower, debuffs) const nextHealth = healMember(member, groupPower, debuffs)
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
@@ -687,29 +763,30 @@ export function PvPRoguelikeScreen({
const castPlayerSpell = useCallback((spell: Spell) => { const castPlayerSpell = useCallback((spell: Spell) => {
if (status !== 'playing' || playerDone || !playerAlive) return if (status !== 'playing' || playerDone || !playerAlive) return
const targetId = selectedIdRef.current
const succeeded = applySpell(playerRef.current, (value) => { const succeeded = applySpell(playerRef.current, (value) => {
const next = typeof value === 'function' ? value(playerRef.current) : value const next = typeof value === 'function' ? value(playerRef.current) : value
playerRef.current = next playerRef.current = next
setPlayerSide(next) setPlayerSide(next)
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, selectedId) }, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId)
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === selectedId)?.name ?? 'target'}.`, 'heal') if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
}, [addLog, applySpell, playerAlive, playerDone, selectedId, status]) }, [addLog, applySpell, playerAlive, playerDone, status])
const selectRelativeTarget = useCallback((direction: -1 | 1) => { const selectRelativeTarget = useCallback((direction: -1 | 1) => {
const living = playerRef.current.party.filter((member) => member.health > 0) const living = playerRef.current.party.filter((member) => member.health > 0)
if (living.length === 0) return if (living.length === 0) return
const currentIndex = living.findIndex((member) => member.id === selectedId) const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
const nextIndex = currentIndex < 0 const nextIndex = currentIndex < 0
? 0 ? 0
: (currentIndex + direction + living.length) % living.length : (currentIndex + direction + living.length) % living.length
setSelectedId(living[nextIndex].id) setSelectedTargetId(living[nextIndex].id)
}, [selectedId]) }, [setSelectedTargetId])
const selectDirectionalTarget = useCallback((action: InputAction) => { const selectDirectionalTarget = useCallback((action: InputAction) => {
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedId) const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
if (currentIndex < 0) { if (currentIndex < 0) {
const firstLiving = playerRef.current.party.find((member) => member.health > 0) const firstLiving = playerRef.current.party.find((member) => member.health > 0)
if (firstLiving) setSelectedId(firstLiving.id) if (firstLiving) setSelectedTargetId(firstLiving.id)
return return
} }
const currentRow = Math.floor(currentIndex / partyColumns) const currentRow = Math.floor(currentIndex / partyColumns)
@@ -736,14 +813,14 @@ export function PvPRoguelikeScreen({
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn) const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
return aPrimary - bPrimary || aSecondary - bSecondary return aPrimary - bPrimary || aSecondary - bSecondary
}) })
if (candidates[0]) setSelectedId(candidates[0].member.id) if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
}, [partyColumns, selectedId]) }, [partyColumns, setSelectedTargetId])
const selectDirectTarget = useCallback((slot: number) => { const selectDirectTarget = useCallback((slot: number) => {
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0) const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
const member = playerRef.current.party[index] const member = playerRef.current.party[index]
if (member?.health > 0) setSelectedId(member.id) if (member?.health > 0) setSelectedTargetId(member.id)
}, [contentType, targetGroup]) }, [contentType, setSelectedTargetId, targetGroup])
const cpuTakeTurn = useCallback(() => { const cpuTakeTurn = useCallback(() => {
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
@@ -790,10 +867,14 @@ export function PvPRoguelikeScreen({
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction') const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison') const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
const damageMultiplier = incomingDamageMultiplier(side.debuffs) const damageMultiplier = incomingDamageMultiplier(side.debuffs)
const tankPressure = tankPressureTargets(side.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const nextParty = side.party.map((member) => { const nextParty = side.party.map((member) => {
if (member.health <= 0) return member if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounterValue.damage : 0 let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
if (member.role === 'Tank') damage += encounterValue.tankDamage if (tankPressureIds.has(member.id)) {
damage += Math.round(encounterValue.tankDamage * tankPressure.multiplier)
}
if (bossPulse) damage += 10 if (bossPulse) damage += 10
if (member.debuff) damage += 6 if (member.debuff) damage += 6
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
@@ -842,7 +923,7 @@ export function PvPRoguelikeScreen({
cooldowns: Object.fromEntries( cooldowns: Object.fromEntries(
Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]), Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
), ),
enemyHealth: Math.max(0, side.enemyHealth - encounterValue.partyDamage), enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
} }
}, [addFloatingHeal, elapsedTicks, maxResource]) }, [addFloatingHeal, elapsedTicks, maxResource])
@@ -895,6 +976,12 @@ export function PvPRoguelikeScreen({
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot') addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
} }
if (nextPlayer.enemyHealth <= 0) { if (nextPlayer.enemyHealth <= 0) {
if (encounter.isBoss && cpuDefeatedRef.current) {
finishRoguelikeRun()
setStatus('won')
addLog('CPU defeated. Match complete.', 'loot')
return
}
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot') addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
beginUpgradePhase() beginUpgradePhase()
} }
@@ -955,10 +1042,17 @@ export function PvPRoguelikeScreen({
} }
const clearedBoss = encounter.isBoss const clearedBoss = encounter.isBoss
if (clearedBoss && cpuDefeatedRef.current) {
finishRoguelikeRun()
setStatus('won')
addLog('CPU defeated. Match complete.', 'loot')
return
}
const nextStage = clearedBoss ? stage + 1 : stage const nextStage = clearedBoss ? stage + 1 : stage
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : [] const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1] const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
if (!nextEncounter) { if (!nextEncounter) {
finishRoguelikeRun()
setStatus('won') setStatus('won')
addLog('No further encounters remain.', 'loot') addLog('No further encounters remain.', 'loot')
return return
@@ -1007,7 +1101,7 @@ export function PvPRoguelikeScreen({
setElapsedTicks(0) setElapsedTicks(0)
setStatus('playing') setStatus('playing')
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system') addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells]) }, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
useGameAction((action) => { useGameAction((action) => {
if (action === 'pause' || action === 'back') { if (action === 'pause' || action === 'back') {
@@ -1036,9 +1130,9 @@ export function PvPRoguelikeScreen({
setTargetGroup((current) => { setTargetGroup((current) => {
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6)) const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
const next = ((current + 1) % groupCount) as 0 | 1 | 2 const next = ((current + 1) % groupCount) as 0 | 1 | 2
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedId) const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6] const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
if (nextMember?.health > 0) setSelectedId(nextMember.id) if (nextMember?.health > 0) setSelectedTargetId(nextMember.id)
return next return next
}) })
return return
@@ -1128,7 +1222,7 @@ export function PvPRoguelikeScreen({
{dualScreenEnabled && status !== 'queueing' && ( {dualScreenEnabled && status !== 'queueing' && (
<DualScreenTopCombat <DualScreenTopCombat
state={dualScreenState} state={dualScreenState}
onSelectTarget={setSelectedId} onSelectTarget={setSelectedTargetId}
/> />
)} )}
@@ -1152,7 +1246,7 @@ export function PvPRoguelikeScreen({
<button <button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`} className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
key={`player-${member.id}`} key={`player-${member.id}`}
onClick={() => setSelectedId(member.id)} onClick={() => setSelectedTargetId(member.id)}
type="button" type="button"
> >
<div className="member-header"> <div className="member-header">
@@ -1351,9 +1445,39 @@ export function PvPRoguelikeScreen({
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2> <h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
<p>{finalEncountersCleared} encounters cleared.</p> <p>{finalEncountersCleared} encounters cleared.</p>
<div className="reward-summary"> <div className="reward-summary">
{!reward && !rewardError && <p>Boss kills grant XP immediately.</p>} <p>{runSummary.bossesKilled} bosses killed.</p>
<p>+{runSummary.experienceGained} XP</p>
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
{rewardError && <p className="reward-error">{rewardError}</p>} {rewardError && <p className="reward-error">{rewardError}</p>}
{reward && ( {runSummary.levelsGained > 0 && runSummary.previousLevel !== null && runSummary.newLevel !== null && (
<p className="level-gain">
Level {runSummary.previousLevel} to {runSummary.newLevel}
<small>+{runSummary.talentPointsGained} talent point{runSummary.talentPointsGained === 1 ? '' : 's'}</small>
</p>
)}
{runSummary.unlockedAbilities.map((ability) => (
<p className="ability-unlock" key={ability.id}>
<span>{ability.glyph}</span>
Ability Unlocked: {ability.name}
</p>
))}
<div className="run-loot-rolls">
{runSummary.loot.length > 0 ? runSummary.loot.map((item, index) => (
<div className="dropped" key={`${item.id}-${index}`}>
<strong>Boss {index + 1}</strong>
<span>
{item.glyph} {item.name} x{item.quantity}
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
</span>
</div>
)) : (
<div>
<strong>Loot</strong>
<span>No boss loot awarded</span>
</div>
)}
</div>
{reward && runSummary.bossesKilled === 0 && (
<> <>
<p>+{reward.experienceGained} XP</p> <p>+{reward.experienceGained} XP</p>
{reward.levelsGained > 0 && ( {reward.levelsGained > 0 && (
@@ -1392,6 +1516,7 @@ export function PvPRoguelikeScreen({
)} )}
</> </>
)} )}
<button onClick={() => startMatch()} type="button">Queue Next Match</button>
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button> <button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
</div> </div>
</div> </div>
+100 -1
View File
@@ -56,12 +56,28 @@ export type DualScreenCombatState = {
targetGroup: 0 | 1 | 2 targetGroup: 0 | 1 | 2
} }
export type DualScreenWorkshopState = {
mode: 'class' | 'equipment' | 'crafting' | 'talents'
title: string
subtitle: string
summary?: string
items: Array<{
glyph?: string
title: string
meta?: string
detail?: string
status?: string
}>
}
type DualScreenMessage = type DualScreenMessage =
| { type: 'combat-state'; state: DualScreenCombatState } | { type: 'combat-state'; state: DualScreenCombatState }
| { type: 'workshop-state'; state: DualScreenWorkshopState }
| { type: 'companion-ready' } | { type: 'companion-ready' }
| { type: 'companion-heartbeat' } | { type: 'companion-heartbeat' }
| { type: 'control-action'; action: InputAction } | { type: 'control-action'; action: InputAction }
| { type: 'combat-ended' } | { type: 'combat-ended' }
| { type: 'workshop-ended' }
type DualScreenContextValue = { type DualScreenContextValue = {
enabled: boolean enabled: boolean
@@ -280,16 +296,64 @@ export function useDualScreenPublisher(
}, [enabled, state]) }, [enabled, state])
} }
export function useDualScreenWorkshopPublisher(
state: DualScreenWorkshopState | null,
enabled: boolean,
) {
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
if (!channel) return
const publish = () => {
if (stateRef.current) {
channel.postMessage({
type: 'workshop-state',
state: stateRef.current,
} satisfies DualScreenMessage)
}
}
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
}
publish()
return () => {
channel.postMessage({ type: 'workshop-ended' } satisfies DualScreenMessage)
channel.close()
}
}, [enabled, state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
channel?.postMessage({ type: 'workshop-state', state } satisfies DualScreenMessage)
channel?.close()
}, [enabled, state])
}
export function DualScreenBottomDisplay() { export function DualScreenBottomDisplay() {
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot) const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
useEffect(() => { useEffect(() => {
const channel = createChannel() const channel = createChannel()
if (!channel) return if (!channel) return
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage) const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => { channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'combat-state') setState(event.data.state) if (event.data.type === 'combat-state') {
setState(event.data.state)
setWorkshopState(null)
}
if (event.data.type === 'workshop-state') {
setWorkshopState(event.data.state)
setState(null)
}
if (event.data.type === 'combat-ended') setState(null) if (event.data.type === 'combat-ended') setState(null)
if (event.data.type === 'workshop-ended') setWorkshopState(null)
} }
announce() announce()
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
@@ -307,6 +371,40 @@ export function DualScreenBottomDisplay() {
channel?.close() channel?.close()
} }
if (!state && workshopState) {
return (
<main className="dual-bottom-display workshop-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{workshopState.mode}</p>
<h1>{workshopState.title}</h1>
</div>
<div className="dual-controls-progress">
<span>{workshopState.subtitle}</span>
</div>
</header>
{workshopState.summary && (
<section className="workshop-bottom-summary">
{workshopState.summary}
</section>
)}
<section className="workshop-bottom-grid">
{workshopState.items.map((item, index) => (
<article key={`${item.title}-${index}`}>
{item.glyph && <span>{item.glyph}</span>}
<div>
<strong>{item.title}</strong>
{item.meta && <small>{item.meta}</small>}
{item.detail && <p>{item.detail}</p>}
</div>
{item.status && <i>{item.status}</i>}
</article>
))}
</section>
</main>
)
}
if (!state) { if (!state) {
return ( return (
<main className="dual-bottom-display dual-bottom-waiting"> <main className="dual-bottom-display dual-bottom-waiting">
@@ -475,6 +573,7 @@ export function DualScreenTopCombat({
return ( return (
<button <button
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`} className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
data-party-member-id={member.id}
key={member.id} key={member.id}
onClick={() => onSelectTarget(member.id)} onClick={() => onSelectTarget(member.id)}
type="button" type="button"
+29 -1
View File
@@ -44,6 +44,9 @@ export type CombatLogEntry = {
tone: 'system' | 'heal' | 'danger' | 'loot' tone: 'system' | 'heal' | 'danger' | 'loot'
} }
export const TANKLESS_DAMAGE_MULTIPLIER = 1.35
export const DEFAULT_GROUP_HEAL_TARGETS = 4
export const INITIAL_PARTY: PartyMember[] = [ export const INITIAL_PARTY: PartyMember[] = [
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 }, { id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 }, { id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
@@ -101,7 +104,7 @@ export const SPELLS: Spell[] = [
id: 'radiance', id: 'radiance',
key: '3', key: '3',
name: 'Radiance', name: 'Radiance',
description: 'Restores health to every living party member.', description: 'Restores health to up to 4 injured party members.',
cost: 12, cost: 12,
cooldown: 8, cooldown: 8,
power: 18, power: 18,
@@ -164,3 +167,28 @@ export const ENCOUNTERS: Encounter[] = [
isBoss: true, isBoss: true,
}, },
] ]
export function partyDamageOutput(party: PartyMember[], baseDamage: number) {
const livingCount = party.filter((member) => member.health > 0).length
return Math.round(baseDamage * (livingCount / Math.max(1, party.length)))
}
export function tankPressureTargets(party: PartyMember[]) {
const living = party.filter((member) => member.health > 0)
const tanks = living.filter((member) => member.role === 'Tank')
if (tanks.length > 0) return { targets: tanks, multiplier: 1 }
const damageDealer = living
.filter((member) => member.role === 'Damage')
.sort((left, right) => right.health - left.health)[0]
return {
targets: damageDealer ? [damageDealer] : [],
multiplier: TANKLESS_DAMAGE_MULTIPLIER,
}
}
export function groupHealTargets(party: PartyMember[], targetCount = DEFAULT_GROUP_HEAL_TARGETS) {
return party
.filter((member) => member.health > 0)
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))
.slice(0, targetCount)
}
+11 -6
View File
@@ -385,9 +385,7 @@ function scaledPvpBossExperience(
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string } type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = { const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' }, 1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
5: { id: 601, slug: 'basic-component', name: 'Basic Component', itemLevel: 5, glyph: '◇', description: 'A standard crafting component.' },
10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined crafting component.' }, 10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined crafting component.' },
15: { id: 603, slug: 'advanced-component', name: 'Advanced Component', itemLevel: 15, glyph: '◉', description: 'An advanced crafting component.' },
20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' }, 20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' }, 25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
} }
@@ -760,8 +758,7 @@ function emptyCharacterData(classId: number): CharacterData {
const gc = static_.classes.find((c) => c.id === classId)! const gc = static_.classes.find((c) => c.id === classId)!
const talentRanks: Record<string, number> = {} const talentRanks: Record<string, number> = {}
for (const t of gc.talents) talentRanks[String(t.id)] = 0 for (const t of gc.talents) talentRanks[String(t.id)] = 0
const starterItemIds = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109] const inventory: Item[] = []
const inventory: Item[] = static_.inventory.filter((i) => starterItemIds.includes(i.id)).map((i) => ({ ...i }))
const startingAbilitySlots: Array<number | null> = gc.spells const startingAbilitySlots: Array<number | null> = gc.spells
.filter((s) => s.unlockLevel === 1) .filter((s) => s.unlockLevel === 1)
.slice(0, 5) .slice(0, 5)
@@ -1164,6 +1161,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const profile = buildProfile(save) const profile = buildProfile(save)
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId) const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.') if (!recipe) throw new Error('That crafting recipe does not exist.')
const requiresUpgrade = profile.craftingRecipes.some((candidate) =>
candidate.sourceEncounterId === recipe.sourceEncounterId
&& candidate.item.slot === recipe.item.slot
&& candidate.item.itemLevel < recipe.item.itemLevel,
)
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
const missing = recipe.components.find((component) => component.owned < component.quantity) const missing = recipe.components.find((component) => component.owned < component.quantity)
if (missing) { if (missing) {
throw new Error(`Need ${missing.quantity} ${missing.item.name} to craft this item.`) throw new Error(`Need ${missing.quantity} ${missing.item.name} to craft this item.`)
@@ -1191,11 +1194,13 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
if (item.slot === 'component') throw new Error('Components cannot be upgraded.') if (item.slot === 'component') throw new Error('Components cannot be upgraded.')
const currentRecipe = profile.craftingRecipes.find((recipe) => recipe.item.id === item.id) const currentRecipe = profile.craftingRecipes.find((recipe) => recipe.item.id === item.id)
const targetRecipe = currentRecipe const targetRecipe = currentRecipe
? profile.craftingRecipes.find((recipe) => ? profile.craftingRecipes
.filter((recipe) =>
recipe.sourceEncounterId === currentRecipe.sourceEncounterId recipe.sourceEncounterId === currentRecipe.sourceEncounterId
&& recipe.item.slot === item.slot && recipe.item.slot === item.slot
&& recipe.item.itemLevel === item.itemLevel + 5, && recipe.item.itemLevel > item.itemLevel,
) )
.sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0]
: null : null
if (!targetRecipe) throw new Error('No upgrade is available for this item.') if (!targetRecipe) throw new Error('No upgrade is available for this item.')
const missing = targetRecipe.components.find((component) => component.owned < component.quantity) const missing = targetRecipe.components.find((component) => component.owned < component.quantity)
-14
View File
@@ -276,14 +276,6 @@ function hasUiOverlay() {
).some(isVisible) ).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> = { const BUTTON_LABELS: Record<number, string> = {
0: 'A / Cross', 0: 'A / Cross',
1: 'B / Circle', 1: 'B / Circle',
@@ -397,7 +389,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
const keyboardInputRef = useRef(keyboardInput) const keyboardInputRef = useRef(keyboardInput)
const previousTokensRef = useRef(new Set<string>()) const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({}) const repeatRef = useRef<Record<string, number>>({})
const lastCombatNavigationRef = useRef(0)
useEffect(() => { useEffect(() => {
bindingsRef.current = bindings bindingsRef.current = bindings
@@ -444,11 +435,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => { const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
const uiOverlay = hasUiOverlay() const uiOverlay = hasUiOverlay()
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]')) 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) setLastDevice(device)
document.documentElement.dataset.inputDevice = device document.documentElement.dataset.inputDevice = device
File diff suppressed because it is too large Load Diff