Compare commits

..

22 Commits

Author SHA1 Message Date
Warren H 5aac39c6c9 Android build v1.0.42 2026-06-20 20:49:46 -04:00
Warren H 224249e372 Android build v1.0.41 2026-06-20 20:42:10 -04:00
Warren H 66f5af4484 Android build v1.0.40 2026-06-20 20:16:20 -04:00
Warren H 8f5a957963 Android build v1.0.39 2026-06-20 20:09:57 -04:00
Warren H f8a1fbc5e2 Android build v1.0.38 2026-06-20 18:06:39 -04:00
Warren H bab2dce6c3 Android build v1.0.37 2026-06-20 17:52:12 -04:00
Warren H cb38042eca Android build v1.0.37 2026-06-20 17:50:57 -04:00
Warren H 753bba581a Android build v1.0.36 2026-06-20 16:57:03 -04:00
Warren H 2300973164 Android build v1.0.35 2026-06-20 16:10:35 -04:00
Warren H 1281be69d8 Android build v1.0.34 2026-06-20 15:08:51 -04:00
Warren H 4fc15ebe9a Android build v1.0.33 2026-06-20 13:21:49 -04:00
Warren H 7313c968e6 Android build v1.0.32 2026-06-20 12:50:48 -04:00
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
Warren H 3a8d5ad8c5 changes 2026-06-18 22:28:04 -04:00
55 changed files with 11639 additions and 7994 deletions
+3
View File
@@ -2,5 +2,8 @@
- 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.
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
- 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.
+221
View File
@@ -43,6 +43,227 @@ Keep the game server bound to `127.0.0.1`. Set `TRUST_PROXY=1` only when the
server can be reached solely through your local reverse proxy. This lets account server can be reached solely through your local reverse proxy. This lets account
limits use the visitor's public IP instead of the proxy's address. limits use the visitor's public IP instead of the proxy's address.
## TrueNAS single-container hosting
### TrueNAS SCALE runbook
This is the simplest TrueNAS setup. One container serves the browser game,
auth routes, game API routes, and one SQLite database. Use this when you want
`iwanttoheal.phenomrom.com` to host the playable browser version and you want
code updates to be a Git pull plus app restart.
Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**.
Repository:
```text
https://git.whoagland.com/phenom/i-want-to-heal.git
```
TrueNAS paths:
```text
/mnt/usbssds/apps/iwanttoheal/app
/mnt/usbssds/apps/iwanttoheal/data
```
Create the app directory and clone the repo:
```sh
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal
cd /mnt/usbssds/apps/iwanttoheal
sudo git clone https://git.whoagland.com/phenom/i-want-to-heal.git app
```
Because the clone was run with `sudo`, give the normal TrueNAS user ownership:
```sh
sudo chown -R truenas_admin:truenas_admin /mnt/usbssds/apps/iwanttoheal
```
Create the persistent data folder:
```sh
mkdir -p /mnt/usbssds/apps/iwanttoheal/data
```
Check that the production server file exists:
```sh
ls /mnt/usbssds/apps/iwanttoheal/app/server/production.mjs
```
If that file is missing, push the latest code to `git.whoagland.com` from the
development machine, then pull on TrueNAS:
```sh
cd /mnt/usbssds/apps/iwanttoheal/app
git pull
```
If Git fails with `chmod ... Operation not permitted`, do not use a media or SMB
dataset for the repo. Git needs normal file locking and chmod behavior. Create or
use a dedicated apps dataset and clone under `/mnt/usbssds/apps/...`.
### TrueNAS app YAML
In TrueNAS:
1. Open **Apps**.
2. Open **Discover**.
3. Click the three-dot menu.
4. Choose **Install via YAML**.
5. Name the app `iwanttoheal`.
6. Paste this YAML:
```yaml
services:
iwanttoheal:
image: node:24-bookworm-slim
working_dir: /app
command: sh -lc "npm ci && npm run db:init && npm run build && npm start"
environment:
HOST: 0.0.0.0
PORT: "4173"
TRUST_PROXY: "1"
COOKIE_SECURE: "1"
CORS_ORIGINS: "http://localhost,https://localhost,capacitor://localhost,https://iwanttoheal.phenomrom.com,https://auth.phenomrom.com"
ports:
- "4173:4173"
volumes:
- /mnt/usbssds/apps/iwanttoheal/app:/app
- /mnt/usbssds/apps/iwanttoheal/data:/app/data
restart: unless-stopped
```
The app listens inside Docker on port `4173`. The database lives at
`/mnt/usbssds/apps/iwanttoheal/data/game.db` because that host directory is
mounted into the container as `/app/data`. This is persistent runtime data, not
code. Do not commit it and do not copy the Mac `data/game.db` over it during
deploys.
The startup command installs dependencies, applies schema/static-content
updates, builds the web app, and starts the production server.
Test the local TrueNAS service:
```sh
curl http://TRUENAS-IP:4173/api/auth/session
```
Expected response:
```json
{"account":null,"profile":null}
```
### Reverse proxy
Point `iwanttoheal.phenomrom.com` at the TrueNAS app through HTTPS. Do not expose
port `4173` directly to the internet. Put Caddy or another reverse proxy in
front:
```caddyfile
iwanttoheal.phenomrom.com {
reverse_proxy TRUENAS-IP:4173
}
auth.phenomrom.com {
reverse_proxy TRUENAS-IP:4173
}
```
Both hostnames can point at the same container. `iwanttoheal.phenomrom.com`
serves the browser game. `auth.phenomrom.com` stays available as an auth URL for
Android or other clients that need a dedicated auth hostname.
DNS should point both hostnames at the public IP or dynamic DNS name that reaches
the reverse proxy. Forward public ports `80` and `443` to the reverse proxy host.
Test the public game and auth URLs:
```sh
curl https://iwanttoheal.phenomrom.com
curl https://auth.phenomrom.com/api/auth/session
```
Expected auth response:
```json
{"account":null,"profile":null}
```
### App build config
For the hosted browser game, no separate auth build setting is needed. The web
app can call same-origin routes like `/api/auth/login` and `/api/profile`.
For an Android build that should use the TrueNAS-hosted game API, build with:
```sh
npm run android:apk:truenas
```
If you intentionally want Android auth calls to use `auth.phenomrom.com`, also
set `VITE_AUTH_API_BASE_URL=https://auth.phenomrom.com`. Otherwise, leave it
unset and auth uses the same base URL as the game API.
Android runs the bundled web app from a local Capacitor origin, not from
`iwanttoheal.phenomrom.com`. The hosted server must allow that origin through
CORS, which is why the TrueNAS YAML includes `http://localhost`,
`https://localhost`, and `capacitor://localhost`.
### Updating the TrueNAS game app
Push changes from the development machine to `git.whoagland.com`, then pull them
on TrueNAS:
```sh
cd /mnt/usbssds/apps/iwanttoheal/app
git pull
```
Before restarting, back up the persistent database:
```sh
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
```
Restart the `iwanttoheal` app in the TrueNAS Apps UI after pulling. The app
command runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start` on
startup, so dependency, schema, and browser bundle changes are applied each time
the container restarts.
`npm run db:init` updates schema and seeded static game content. It should not
erase accounts, characters, inventory, or save progress. Character resets are
separate manual operations and should only be run intentionally.
Normal update workflow:
```sh
# development machine
git add .
git commit -m "Update game"
git push origin main
# TrueNAS shell
cd /mnt/usbssds/apps/iwanttoheal/app
git pull
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
```
Then restart the TrueNAS app.
For the shorter checklist, see [docs/push-updates.md](docs/push-updates.md).
### Existing auth-only app
If `iwanttoheal-auth` was already created during earlier testing, the simplest
path is to stop that app and use the single `iwanttoheal` app above. The single
container serves both domains and avoids two processes sharing one SQLite file.
## Account limits ## Account limits
Registration permits one account per public IP by default. Login and API rate Registration permits one account per public IP by default. Login and API rate
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 32 versionCode 62
versionName "1.0.21" versionName "1.0.42"
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
+950 -55
View File
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
# Gearing System
## Current Rule
The game uses fewer playable content tiers and more gear upgrade steps.
Content tiers:
| Content Tier | Unlock Level | Purpose |
| --- | ---: | --- |
| iLvl 1 | 1 | First real gear set |
| iLvl 10 | 10 | Midgame jump |
| iLvl 20 | 20 | Endgame jump |
| iLvl 25 | 25 | Hard endgame |
Gear tiers:
| Gear Tier | How It Is Used |
| ---: | --- |
| 1 | Crafted from iLvl 1 content |
| 5 | Upgrade tier from iLvl 1 content coins |
| 10 | Crafted/upgraded from iLvl 10 content coins |
| 15 | Gear-only upgrade tier from iLvl 10 content coins |
| 20 | Crafted/upgraded from iLvl 20 content coins |
| 25 | Crafted/upgraded from iLvl 25 content coins |
This keeps the dungeon/raid picker simple while still giving players steady gear goals.
## New Characters
New characters start with no gear equipped.
The first goal is to clear iLvl 1 content, earn raw boss coins, and craft the first iLvl 1 set. Starter gear exists as craftable gear, not automatic inventory.
## Coin Tiers
Coins are component items. Each coin is tied to a boss and a content tier.
| Content Tier | Coin Prefix | Rarity Key | Example |
| ---: | --- | --- | --- |
| 1 | Raw | common | Raw Bulldrome Coin |
| 10 | Green | uncommon | Green Bulldrome Coin |
| 20 | Purple | epic | Purple Bulldrome Coin |
| 25 | Orange | legendary | Orange Bulldrome Coin |
iLvl 5 and iLvl 15 gear do not have their own playable content tier. They use coins from the previous playable tier:
| Gear Tier | Coin Tier Used |
| ---: | ---: |
| 1 | 1 |
| 5 | 1 |
| 10 | 10 |
| 15 | 10 |
| 20 | 20 |
| 25 | 25 |
## Boss Loot
Each boss drops one boss coin for the selected content tier.
Examples:
- Bulldrome at iLvl 1 drops Raw Bulldrome Coins.
- Tigrex at iLvl 10 drops Green Tigrex Coins.
- Barroth at iLvl 20 drops Purple Barroth Coins.
- Anjanath at iLvl 25 drops Orange Anjanath Coins.
## Crafting And Upgrades
The first gear item in a boss/item line can be crafted directly. Higher versions should be reached through Upgrade.
Current rule:
- Craft iLvl 1 boss gear directly.
- Upgrade iLvl 1 -> 5 with iLvl 1 coins.
- Craft or upgrade iLvl 10 gear from iLvl 10 content.
- Upgrade iLvl 10 -> 15 with iLvl 10 coins.
- Craft or upgrade iLvl 20 gear from iLvl 20 content.
- Craft or upgrade iLvl 25 gear from iLvl 25 content.
Upgrade consumes the old item and awards the upgraded item. This avoids duplicate clutter and keeps item identity clear.
Examples:
| Upgrade | Cost Source |
| --- | --- |
| Raw Bulldrome Helmet iLvl 1 -> Honed Bulldrome Helmet iLvl 5 | Raw Bulldrome Coins |
| Green Tigrex Helmet iLvl 10 -> Blue Tigrex Helmet iLvl 15 | Green Tigrex Coins |
| Purple Bulldrome Helmet iLvl 20 -> Orange Bulldrome Helmet iLvl 25 | Orange Bulldrome Coins |
## UI Behavior
The dungeon and raid picker only shows playable content tiers:
```text
iLvl 1 -> iLvl 10 -> iLvl 20 -> iLvl 25
```
The equipment screen still shows gear recipes for:
```text
iLvl 1 -> iLvl 5 -> iLvl 10 -> iLvl 15 -> iLvl 20 -> iLvl 25
```
Direct crafting is blocked for recipes that have a lower item-level version in the same boss/item line. Use the selected item's Upgrade button for those.
## Roguelike Loot
Roguelike gear should follow the same tier brackets.
Recommended mapping:
| Stage Band | Coin Tier |
| --- | ---: |
| 1-4 | 1 |
| 5-9 | 10 |
| 10-14 | 10 |
| 15-19 | 20 |
| 20+ | 25 |
Boss-based coins are still preferred. A roguelike boss should award coins for its actual boss template when possible.
## Data Notes
Authoritative gearing data lives in SQLite seed data:
- `db/seed.sql`
- `src/offline-starter-profile.json`
Run this after changing seed data:
```sh
npm run db:init
npm run offline:export
```
`npm run build` already runs `offline:export`, so a production build also refreshes the offline starter profile.
TrueNAS keeps its own persistent `data/game.db`. Pushing code does not merge or replace that database. The TrueNAS app applies seed/schema changes when the container starts and runs `npm run db:init`.
+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="ed2db3fd54546e9658377d0551b3fc3961583f1d"
VERSION="1.0.27"
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

@@ -0,0 +1,67 @@
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="540" viewBox="0 0 620 540" role="img" aria-label="Ayn Thor secondary display spell effect quick swap mockup">
<rect width="620" height="540" fill="#111219"/>
<rect x="16" y="16" width="588" height="48" fill="#20222d" stroke="#0a0b0e" stroke-width="3"/>
<text x="32" y="36" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">SPELL EFFECTS</text>
<text x="32" y="56" fill="#f5e6b2" font-family="monospace" font-size="18" font-weight="900">Quick Swap</text>
<text x="460" y="47" fill="#83d99b" font-family="monospace" font-size="13" font-weight="900">4/4 ACTIVE</text>
<g transform="translate(16 82)">
<rect width="588" height="118" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">ACTIVE SLOTS</text>
<g transform="translate(14 46)">
<rect width="130" height="54" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
<text x="10" y="20" fill="#e5b95f" font-family="monospace" font-size="11" font-weight="900">LV 5</text>
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Mend Renew</text>
</g>
<g transform="translate(154 46)">
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="10" y="20" fill="#6da7df" font-family="monospace" font-size="11" font-weight="900">LV 10</text>
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Rad Shield</text>
</g>
<g transform="translate(294 46)">
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="10" y="20" fill="#4fb978" font-family="monospace" font-size="11" font-weight="900">LV 15</text>
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Shield DR</text>
</g>
<g transform="translate(434 46)">
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="10" y="20" fill="#b16dde" font-family="monospace" font-size="11" font-weight="900">LV 20</text>
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Mend CD</text>
</g>
</g>
<g transform="translate(16 218)">
<rect width="278" height="286" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">POOL</text>
<g transform="translate(14 46)">
<rect width="250" height="42" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
<text x="12" y="26" fill="#f5e6b2" font-family="monospace" font-size="13" font-weight="900">Mend applies Renew</text>
</g>
<g transform="translate(14 98)">
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Shield applies Renew</text>
</g>
<g transform="translate(14 150)">
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Mend adds Shield</text>
</g>
<g transform="translate(14 202)">
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Radiance Renew</text>
</g>
</g>
<g transform="translate(310 218)">
<rect width="294" height="286" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">DETAIL</text>
<text x="16" y="58" fill="#f5e6b2" font-family="monospace" font-size="18" font-weight="900">Mend applies Renew</text>
<text x="16" y="92" fill="#d7dbe0" font-family="monospace" font-size="13">Mend also applies Renew to</text>
<text x="16" y="112" fill="#d7dbe0" font-family="monospace" font-size="13">the target.</text>
<text x="16" y="150" fill="#83d99b" font-family="monospace" font-size="12" font-weight="900">Rule: same HoT refreshes.</text>
<text x="16" y="172" fill="#83d99b" font-family="monospace" font-size="12" font-weight="900">Different HoTs coexist.</text>
<rect x="16" y="216" width="120" height="44" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
<text x="50" y="244" fill="#21180a" font-family="monospace" font-size="14" font-weight="900">Equip</text>
<rect x="154" y="216" width="120" height="44" rx="3" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
<text x="184" y="244" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Clear</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

@@ -0,0 +1,90 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Ayn Thor main display talent effect planner mockup">
<rect width="960" height="540" fill="#111219"/>
<rect x="20" y="18" width="920" height="50" fill="#20222d" stroke="#0a0b0e" stroke-width="3"/>
<text x="38" y="39" fill="#8aa0b7" font-family="monospace" font-size="12" font-weight="700">CHARACTER WORKSHOP</text>
<text x="38" y="59" fill="#f5e6b2" font-family="monospace" font-size="20" font-weight="900">Spell Effects</text>
<rect x="764" y="28" width="154" height="30" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
<text x="786" y="49" fill="#21180a" font-family="monospace" font-size="13" font-weight="900">Save Loadout</text>
<g transform="translate(20 88)">
<rect width="294" height="420" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
<text x="18" y="28" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">UNLOCKED SLOTS</text>
<text x="18" y="53" fill="#f7f2d7" font-family="monospace" font-size="19" font-weight="900">4 active effects</text>
<g transform="translate(16 76)">
<rect width="262" height="58" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
<circle cx="29" cy="29" r="15" fill="#e5b95f"/>
<text x="22" y="34" fill="#21180a" font-family="monospace" font-size="13" font-weight="900">5</text>
<text x="56" y="25" fill="#f5e6b2" font-family="monospace" font-size="15" font-weight="900">Mend applies Renew</text>
<text x="56" y="45" fill="#83d99b" font-family="monospace" font-size="11">Selected</text>
</g>
<g transform="translate(16 148)">
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<circle cx="29" cy="29" r="15" fill="#6da7df"/>
<text x="18" y="34" fill="#08111c" font-family="monospace" font-size="12" font-weight="900">10</text>
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Radiance adds shield</text>
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">30 percent strength</text>
</g>
<g transform="translate(16 220)">
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<circle cx="29" cy="29" r="15" fill="#4fb978"/>
<text x="18" y="34" fill="#071408" font-family="monospace" font-size="12" font-weight="900">15</text>
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Shielded takes less</text>
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">20 percent damage cut</text>
</g>
<g transform="translate(16 292)">
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<circle cx="29" cy="29" r="15" fill="#b16dde"/>
<text x="18" y="34" fill="#15071c" font-family="monospace" font-size="12" font-weight="900">20</text>
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Mend lowers Radiance</text>
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">-2 sec cooldown</text>
</g>
</g>
<g transform="translate(334 88)">
<rect width="606" height="286" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
<text x="18" y="28" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">EFFECT POOL</text>
<text x="18" y="53" fill="#f7f2d7" font-family="monospace" font-size="19" font-weight="900">Pick effects, swap anytime</text>
<g transform="translate(18 76)">
<rect width="276" height="58" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
<text x="14" y="24" fill="#f5e6b2" font-family="monospace" font-size="14" font-weight="900">Mend applies Renew</text>
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">Direct heal also applies Renew.</text>
<text x="226" y="24" fill="#83d99b" font-family="monospace" font-size="10" font-weight="900">ON</text>
</g>
<g transform="translate(312 76)">
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Shield applies Renew</text>
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">Sun Ward adds a Renew effect.</text>
</g>
<g transform="translate(18 148)">
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Mend adds Shield</text>
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">50 percent shield strength.</text>
</g>
<g transform="translate(312 148)">
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Radiance adds Shield</text>
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">30 percent to affected allies.</text>
</g>
<g transform="translate(18 220)">
<rect width="276" height="46" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="14" y="20" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Radiance applies Renew</text>
<text x="14" y="38" fill="#b7c3d0" font-family="monospace" font-size="11">50 percent duration.</text>
</g>
<g transform="translate(312 220)">
<rect width="276" height="46" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="14" y="20" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Shielded gets +healing</text>
<text x="14" y="38" fill="#b7c3d0" font-family="monospace" font-size="11">20 percent more healing.</text>
</g>
</g>
<g transform="translate(334 392)">
<rect width="606" height="116" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
<text x="18" y="26" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">SELECTED EFFECT</text>
<text x="18" y="52" fill="#f5e6b2" font-family="monospace" font-size="20" font-weight="900">Mend applies Renew</text>
<text x="18" y="78" fill="#d7dbe0" font-family="monospace" font-size="13">Casting Mend also applies Renew to the same target. Renew refreshes itself; different HoTs coexist.</text>
<rect x="470" y="32" width="112" height="42" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
<text x="497" y="58" fill="#21180a" font-family="monospace" font-size="14" font-weight="900">Equip</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

+3
View File
@@ -8,14 +8,17 @@
"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",
"offline:export": "node scripts/export-offline-profile.mjs", "offline:export": "node scripts/export-offline-profile.mjs",
"lint": "eslint .", "lint": "eslint .",
"admin:start": "node server/admin.mjs", "admin:start": "node server/admin.mjs",
"auth:start": "node server/auth.mjs",
"start": "node server/production.mjs", "start": "node server/production.mjs",
"prepreview": "npm run db:init", "prepreview": "npm run db:init",
"preview": "vite preview" "preview": "vite preview"
+18
View File
@@ -0,0 +1,18 @@
import { createServer } from 'node:http'
import { handleAuthApiRequest } from './game-api.mjs'
process.env.NODE_ENV = process.env.NODE_ENV ?? 'production'
process.env.CORS_ORIGINS = process.env.CORS_ORIGINS
?? process.env.AUTH_CORS_ORIGINS
?? '*'
const host = process.env.AUTH_HOST ?? process.env.HOST ?? '127.0.0.1'
const port = Number(process.env.AUTH_PORT ?? process.env.PORT ?? 4174)
const server = createServer((request, response) => {
handleAuthApiRequest(request, response)
})
server.listen(port, host, () => {
console.log(`I want to Heal auth listening on http://${host}:${port}`)
})
+463 -82
View File
@@ -22,6 +22,7 @@ const bossImageContentTypes = {
} }
const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket'] const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket']
const componentSlot = 'component' const componentSlot = 'component'
const directCraftItemLevels = new Set([1, 10, 20, 25])
const sessionCookieName = 'chronicle_session' const sessionCookieName = 'chronicle_session'
const sessionLifetimeSeconds = 60 * 60 * 24 * 30 const sessionLifetimeSeconds = 60 * 60 * 24 * 30
const rateLimitBuckets = new Map() const rateLimitBuckets = new Map()
@@ -33,6 +34,31 @@ function sendJson(response, status, body, headers = {}) {
response.end(JSON.stringify(body)) response.end(JSON.stringify(body))
} }
function configuredCorsOrigins() {
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
}
function setCorsHeaders(response, request) {
const origin = request.headers.origin
if (typeof origin !== 'string') return
const allowedOrigins = configuredCorsOrigins()
if (!allowedOrigins.includes('*') && !allowedOrigins.includes(origin)) return
response.setHeader('Access-Control-Allow-Origin', origin)
response.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,OPTIONS')
response.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
response.setHeader('Access-Control-Max-Age', '86400')
response.setHeader('Vary', 'Origin')
}
function sendCorsPreflight(request, response) {
setCorsHeaders(response, request)
response.statusCode = 204
response.end()
}
async function readJson(request, maxSize = 16 * 1024) { async function readJson(request, maxSize = 16 * 1024) {
const chunks = [] const chunks = []
let size = 0 let size = 0
@@ -207,6 +233,25 @@ function consumeRateLimit(key, limit, windowMs) {
} }
} }
function catchUpExperienceReward(database, accountId, characterId, baseReward, currentExperience, currentLevel) {
const targetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level
FROM characters
WHERE account_id = ?
AND id != ?
`).get(accountId, characterId).level
if (targetLevel <= currentLevel) return baseReward
const targetExperience = database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(targetLevel)?.experienceRequired ?? currentExperience
const gap = Math.max(0, targetExperience - currentExperience)
if (gap <= 0) return baseReward
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
return doubledBase * 2 + (baseReward - doubledBase)
}
function normalizeUsername(value) { function normalizeUsername(value) {
const username = String(value ?? '').trim() const username = String(value ?? '').trim()
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) { if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
@@ -260,6 +305,17 @@ function parseCookies(request) {
) )
} }
function bearerToken(request) {
const authorization = request.headers.authorization
if (typeof authorization !== 'string') return ''
const match = authorization.match(/^Bearer\s+(.+)$/i)
return match ? match[1].trim() : ''
}
function requestSessionToken(request) {
return bearerToken(request) || parseCookies(request)[sessionCookieName] || ''
}
function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) { function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) {
const secure = request.headers['x-forwarded-proto'] === 'https' const secure = request.headers['x-forwarded-proto'] === 'https'
|| Boolean(request.socket.encrypted) || Boolean(request.socket.encrypted)
@@ -284,7 +340,7 @@ function createSession(database, accountId, ip, activeCharacterId) {
} }
function currentSession(database, request) { function currentSession(database, request) {
const token = parseCookies(request)[sessionCookieName] const token = requestSessionToken(request)
if (!token) return null if (!token) return null
return database.prepare(` return database.prepare(`
SELECT SELECT
@@ -327,13 +383,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
} }
@@ -741,6 +790,15 @@ export function getProfile(database, characterId, accountId) {
WHERE rank <= 10 WHERE rank <= 10
ORDER BY dungeonId, difficultyId, startPart, completedParts, rank ORDER BY dungeonId, difficultyId, startPart, completedParts, rank
`).all() `).all()
const dungeonCompletionCounts = new Map(database.prepare(`
SELECT dungeon_id AS dungeonId, COUNT(*) AS count
FROM dungeon_runs
WHERE character_id = ?
AND result = 'victory'
AND start_part = 1
AND completed_parts >= 1
GROUP BY dungeon_id
`).all(characterId).map((row) => [row.dungeonId, row.count]))
const settings = Object.fromEntries( const settings = Object.fromEntries(
database.prepare('SELECT key, value FROM game_settings').all() database.prepare('SELECT key, value FROM game_settings').all()
@@ -820,6 +878,7 @@ export function getProfile(database, characterId, accountId) {
}), }),
dungeons: dungeons.map((dungeon) => ({ dungeons: dungeons.map((dungeon) => ({
...dungeon, ...dungeon,
completionCount: dungeonCompletionCounts.get(dungeon.id) ?? 0,
difficulties: dungeonDifficulties.filter( difficulties: dungeonDifficulties.filter(
(difficulty) => difficulty.dungeonId === dungeon.id, (difficulty) => difficulty.dungeonId === dungeon.id,
), ),
@@ -1268,11 +1327,57 @@ function formatLootRoll(database, context, record, dropChance) {
} }
} }
function componentDropQuantity(droppedItemLevel) { function coinDropQuantity() {
const tier = Math.max(0, Math.floor((droppedItemLevel - 5) / 5)) const roll = Math.random()
const secondChance = Math.min(0.85, 0.35 + tier * 0.12) if (roll < 0.15) return 3
const thirdChance = Math.min(0.6, 0.1 + tier * 0.1) if (roll < 0.5) return 2
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0) return 1
}
function roguelikeCoinItemLevel(stage) {
return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5)
}
function awardRoguelikeCoin(database, characterId, sourceEncounterId, stage) {
if (!sourceEncounterId || !stage) return null
const coin = database.prepare(`
SELECT
items.id,
items.slug,
items.name,
items.slot,
items.rarity,
items.item_level AS itemLevel,
items.healing_power AS healingPower,
items.max_resource_bonus AS maxResourceBonus,
items.glyph,
items.description
FROM encounter_loot
JOIN items ON items.id = encounter_loot.item_id
WHERE encounter_loot.encounter_id = ?
AND items.item_level = ?
ORDER BY encounter_loot.difficulty_id
LIMIT 1
`).get(sourceEncounterId, roguelikeCoinItemLevel(stage))
if (!coin) return null
const quantity = coinDropQuantity()
const previousQuantity = database.prepare(`
SELECT quantity
FROM character_inventory
WHERE character_id = ? AND item_id = ?
`).get(characterId, coin.id)?.quantity ?? 0
database.prepare(`
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, ?, 0)
ON CONFLICT(character_id, item_id)
DO UPDATE SET quantity = quantity + ?
`).run(characterId, coin.id, quantity, quantity)
return {
...coin,
quantity,
duplicate: previousQuantity > 0,
quantityAfter: previousQuantity + quantity,
}
} }
function rollWeightedLootEntry(entries) { function rollWeightedLootEntry(entries) {
@@ -1375,13 +1480,11 @@ function rollEncounterLoot(database, characterId, encounterId, difficultyId, run
} }
const selectedQuantities = new Map() const selectedQuantities = new Map()
const lootChanceSlots = context.contentType === 'raid' ? 8 : 5 if (Math.random() < dropChance) {
for (let index = 0; index < lootChanceSlots; index += 1) {
if (Math.random() >= dropChance) continue
const selected = rollWeightedLootEntry(entries) const selected = rollWeightedLootEntry(entries)
selectedQuantities.set( selectedQuantities.set(
selected.id, selected.id,
(selectedQuantities.get(selected.id) ?? 0) + componentDropQuantity(context.droppedItemLevel), coinDropQuantity(),
) )
} }
@@ -1612,11 +1715,17 @@ 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.')
if (!directCraftItemLevels.has(recipe.itemLevel)) {
throw new Error('Upgrade the previous item tier instead.')
}
const components = database.prepare(` const components = database.prepare(`
SELECT SELECT
@@ -1665,9 +1774,118 @@ function craftItem(database, characterId, recipeId) {
return getProfile(database, characterId) return getProfile(database, characterId)
} }
function upgradeItem(database, characterId, itemId) {
const item = database.prepare(`
SELECT
items.id,
items.name,
items.slot,
items.item_level AS itemLevel,
character_inventory.quantity,
character_inventory.equipped
FROM character_inventory
JOIN items ON items.id = character_inventory.item_id
WHERE character_inventory.character_id = ?
AND items.id = ?
`).get(characterId, itemId)
if (!item) throw new Error('That item is not in the character inventory.')
if (item.slot === componentSlot) throw new Error('Components cannot be upgraded.')
const currentRecipe = database.prepare(`
SELECT source_encounter_id AS sourceEncounterId
FROM crafting_recipes
WHERE item_id = ?
`).get(itemId)
if (!currentRecipe) throw new Error('No upgrade is available for this item.')
const targetRecipe = database.prepare(`
SELECT
crafting_recipes.id,
crafting_recipes.item_id AS itemId
FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id
WHERE crafting_recipes.source_encounter_id = ?
AND items.slot = ?
AND items.item_level > ?
ORDER BY items.item_level
LIMIT 1
`).get(currentRecipe.sourceEncounterId, item.slot, item.itemLevel)
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
const components = database.prepare(`
SELECT
crafting_recipe_components.item_id AS itemId,
crafting_recipe_components.quantity,
COALESCE(character_inventory.quantity, 0) AS owned
FROM crafting_recipe_components
LEFT JOIN character_inventory
ON character_inventory.item_id = crafting_recipe_components.item_id
AND character_inventory.character_id = ?
WHERE crafting_recipe_components.recipe_id = ?
`).all(characterId, targetRecipe.id)
const missing = components.find((component) => component.owned < component.quantity)
if (missing) {
const componentItem = itemById(database, missing.itemId)
throw new Error(`Need ${missing.quantity} ${componentItem?.name ?? 'component'} to upgrade this item.`)
}
database.exec('BEGIN')
try {
for (const component of components) {
database.prepare(`
UPDATE character_inventory
SET quantity = quantity - ?
WHERE character_id = ? AND item_id = ?
`).run(component.quantity, characterId, component.itemId)
}
database.prepare(`
UPDATE character_inventory
SET quantity = quantity - 1,
equipped = 0
WHERE character_id = ? AND item_id = ?
`).run(characterId, itemId)
database.prepare(`
DELETE FROM character_inventory
WHERE character_id = ? AND quantity <= 0
`).run(characterId)
if (item.equipped) {
database.prepare(`
UPDATE character_inventory
SET equipped = 0
WHERE character_id = ?
AND item_id IN (SELECT id FROM items WHERE slot = ?)
`).run(characterId, item.slot)
}
database.prepare(`
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, 1, ?)
ON CONFLICT(character_id, item_id)
DO UPDATE SET quantity = quantity + 1,
equipped = CASE WHEN excluded.equipped = 1 THEN 1 ELSE equipped END
`).run(characterId, targetRecipe.itemId, item.equipped ? 1 : 0)
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
return getProfile(database, characterId)
}
function talentEffectCapacity(level) {
return Math.min(4, Math.max(0, Math.floor(level / 5)))
}
function talentEffectSource(effectType) {
if (effectType.startsWith('mend_')) return 'Mend'
if (effectType.startsWith('radiance_')) return 'Radiance'
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
return effectType
}
function allocateTalent(database, characterId, talentId) { function allocateTalent(database, characterId, talentId) {
const character = database.prepare(` const character = database.prepare(`
SELECT class_id AS classId, talent_points AS talentPoints SELECT class_id AS classId, level, talent_points AS talentPoints
FROM characters FROM characters
WHERE id = ? WHERE id = ?
`).get(characterId) `).get(characterId)
@@ -1679,7 +1897,8 @@ function allocateTalent(database, characterId, talentId) {
max_rank AS maxRank, max_rank AS maxRank,
tier, tier,
prerequisite_talent_id AS prerequisiteTalentId, prerequisite_talent_id AS prerequisiteTalentId,
prerequisite_rank AS prerequisiteRank prerequisite_rank AS prerequisiteRank,
effect_type AS effectType
FROM talents FROM talents
WHERE id = ? WHERE id = ?
`).get(talentId) `).get(talentId)
@@ -1687,6 +1906,60 @@ function allocateTalent(database, characterId, talentId) {
if (!talent || talent.classId !== character.classId) { if (!talent || talent.classId !== character.classId) {
throw new Error('That talent does not belong to the active class.') throw new Error('That talent does not belong to the active class.')
} }
if (character.classId === 1) {
const currentRank = database.prepare(`
SELECT rank
FROM character_talents
WHERE character_id = ? AND talent_id = ?
`).get(characterId, talentId)?.rank ?? 0
database.exec('BEGIN')
try {
if (currentRank > 0) {
database.prepare(`
DELETE FROM character_talents
WHERE character_id = ? AND talent_id = ?
`).run(characterId, talentId)
} else {
const capacity = talentEffectCapacity(character.level)
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
const activeTalents = database.prepare(`
SELECT
talents.id,
talents.name,
talents.effect_type AS effectType
FROM character_talents
JOIN talents ON talents.id = character_talents.talent_id
WHERE character_talents.character_id = ?
AND talents.class_id = ?
AND character_talents.rank > 0
`).all(characterId, character.classId)
const source = talentEffectSource(talent.effectType)
const sourceConflict = activeTalents.find(
(candidate) => candidate.id !== talentId && talentEffectSource(candidate.effectType) === source,
)
if (sourceConflict) {
throw new Error(`Only one ${source} spell effect can be active.`)
}
const activeCount = activeTalents.length
if (activeCount >= capacity) {
throw new Error(`Level ${character.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
}
database.prepare(`
INSERT INTO character_talents (character_id, talent_id, rank)
VALUES (?, ?, 1)
ON CONFLICT(character_id, talent_id)
DO UPDATE SET rank = 1
`).run(characterId, talentId)
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
return getProfile(database, characterId)
}
if (character.talentPoints <= 0) { if (character.talentPoints <= 0) {
throw new Error('No talent points are available.') throw new Error('No talent points are available.')
} }
@@ -1765,11 +2038,13 @@ function resetTalents(database, characterId) {
WHERE character_id = ? WHERE character_id = ?
AND talent_id IN (SELECT id FROM talents WHERE class_id = ?) AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
`).run(characterId, character.classId) `).run(characterId, character.classId)
database.prepare(` if (character.classId !== 1) {
UPDATE characters database.prepare(`
SET talent_points = MIN(level, talent_points + ?) UPDATE characters
WHERE id = ? SET talent_points = MIN(level, talent_points + ?)
`).run(refunded, characterId) WHERE id = ?
`).run(refunded, characterId)
}
database.exec('COMMIT') database.exec('COMMIT')
} catch (error) { } catch (error) {
database.exec('ROLLBACK') database.exec('ROLLBACK')
@@ -1840,12 +2115,21 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3) const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3)
const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3) const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3)
const completedParts = completedPart - startPart + 1 const completedParts = completedPart - startPart + 1
const rewardMultiplier = runMetrics?.hardMode ? 2 : 1
const rawPartDurations = runMetrics?.partDurationSeconds const rawPartDurations = runMetrics?.partDurationSeconds
const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3 const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
? rawPartDurations.map(Number) ? rawPartDurations.map(Number)
: null : null
const experienceReward = Math.round( const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart, dungeon.experienceReward * dungeon.experienceMultiplier * completedPart * rewardMultiplier,
)
const experienceReward = catchUpExperienceReward(
database,
accountId,
characterId,
baseExperienceReward,
character.experience,
character.level,
) )
const newExperience = Math.min(character.experience + experienceReward, maxExperience) const newExperience = Math.min(character.experience + experienceReward, maxExperience)
const newLevel = database.prepare(` const newLevel = database.prepare(`
@@ -1943,17 +2227,18 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
`).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3) `).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3)
if (bonusItems.length > 0) { if (bonusItems.length > 0) {
bonusItem = bonusItems[0] bonusItem = bonusItems[0]
const rewardQuantity = rewardMultiplier
const previousQuantity = database.prepare(` const previousQuantity = database.prepare(`
SELECT quantity FROM character_inventory SELECT quantity FROM character_inventory
WHERE character_id = ? AND item_id = ? WHERE character_id = ? AND item_id = ?
`).get(characterId, bonusItem.id)?.quantity ?? 0 `).get(characterId, bonusItem.id)?.quantity ?? 0
database.prepare(` database.prepare(`
INSERT INTO character_inventory (character_id, item_id, quantity, equipped) INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, 1, 0) VALUES (?, ?, ?, 0)
ON CONFLICT(character_id, item_id) ON CONFLICT(character_id, item_id)
DO UPDATE SET quantity = quantity + 1 DO UPDATE SET quantity = quantity + ?
`).run(characterId, bonusItem.id) `).run(characterId, bonusItem.id, rewardQuantity, rewardQuantity)
bonusItem = { ...bonusItem, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 } bonusItem = { ...bonusItem, quantity: rewardQuantity, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + rewardQuantity }
} }
} }
@@ -2050,6 +2335,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
let newExperience = character.experience let newExperience = character.experience
let newLevel = character.level let newLevel = character.level
if (experienceMode === 'pvp-boss-quarter-level') { if (experienceMode === 'pvp-boss-quarter-level') {
const catchUpTargetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level
FROM characters
WHERE account_id = ?
AND id != ?
`).get(accountId, characterId).level
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) { for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
const currentLevelFloor = database.prepare(` const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired SELECT experience_required AS experienceRequired
@@ -2064,7 +2355,8 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE level = ? WHERE level = ?
`).get(newLevel + 1).experienceRequired `).get(newLevel + 1).experienceRequired
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * 0.25)) const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
newLevel = database.prepare(` newLevel = database.prepare(`
SELECT MAX(level) AS level SELECT MAX(level) AS level
FROM level_progression FROM level_progression
@@ -2072,9 +2364,17 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
`).get(newExperience).level `).get(newExperience).level
} }
} else { } else {
const experienceReward = Math.round( const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3), dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
) )
const experienceReward = catchUpExperienceReward(
database,
accountId,
characterId,
baseExperienceReward,
character.experience,
character.level,
)
newExperience = Math.min(character.experience + experienceReward, maxExperience) newExperience = Math.min(character.experience + experienceReward, maxExperience)
newLevel = database.prepare(` newLevel = database.prepare(`
SELECT MAX(level) AS level SELECT MAX(level) AS level
@@ -2108,6 +2408,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
SET experience = ?, level = ?, talent_points = ? SET experience = ?, level = ?, talent_points = ?
WHERE id = ? WHERE id = ?
`).run(newExperience, newLevel, newTalentPoints, characterId) `).run(newExperience, newLevel, newTalentPoints, characterId)
const bonusItem = awardRoguelikeCoin(
database,
characterId,
Number(runMetrics?.lootSourceEncounterId),
Number(runMetrics?.roguelikeStage),
)
return { return {
dungeonName: `${dungeon.name} Roguelike`, dungeonName: `${dungeon.name} Roguelike`,
@@ -2122,7 +2428,7 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
durationSeconds, durationSeconds,
averageItemLevel, averageItemLevel,
unlockedAbilities, unlockedAbilities,
bonusItem: null, bonusItem,
profile: getProfile(database, characterId, accountId), profile: getProfile(database, characterId, accountId),
} }
} }
@@ -2211,12 +2517,124 @@ export function gameApiPlugin() {
} }
} }
async function handleAuthApiRoute(database, request, response) {
if (request.url === '/api/auth/register' && request.method === 'POST') {
const payload = await readJson(request)
const result = registerAccount(database, request, payload)
sendJson(
response,
201,
{ account: result.account, profile: result.profile, token: result.token },
{ 'Set-Cookie': sessionCookie(result.token, request) },
)
return true
}
if (request.url === '/api/auth/login' && request.method === 'POST') {
const payload = await readJson(request)
const result = loginAccount(database, request, payload)
sendJson(
response,
200,
{ account: result.account, profile: result.profile, token: result.token },
{ 'Set-Cookie': sessionCookie(result.token, request) },
)
return true
}
if (request.url === '/api/auth/session' && request.method === 'GET') {
const session = currentSession(database, request)
if (!session) {
sendJson(response, 200, { account: null, profile: null })
return true
}
sendJson(response, 200, {
account: { id: session.accountId, username: session.username },
profile: getProfile(database, session.characterId, session.accountId),
})
return true
}
if (request.url === '/api/auth/logout' && request.method === 'POST') {
const token = requestSessionToken(request)
if (token) {
database.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash(token))
}
sendJson(
response,
200,
{ ok: true },
{ 'Set-Cookie': sessionCookie('', request, 0) },
)
return true
}
return false
}
export async function handleAuthApiRequest(request, response, next = null) {
if (!request.url?.startsWith('/api/auth/')) {
if (next) {
next()
return
}
sendJson(response, 404, { error: 'API route not found.' })
return
}
if (request.method === 'OPTIONS') {
sendCorsPreflight(request, response)
return
}
setCorsHeaders(response, request)
if (!existsSync(databasePath)) {
sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
return
}
const database = new DatabaseSync(databasePath)
database.exec('PRAGMA foreign_keys = ON')
try {
const ip = requestIp(request)
consumeRateLimit(`auth:${ip}`, 120, 60 * 1000)
database.prepare(`
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
`).run()
if (!(await handleAuthApiRoute(database, request, response))) {
sendJson(response, 404, { error: 'API route not found.' })
}
} catch (error) {
const status = Number(error?.status) || 400
const headers = error?.retryAfter
? { 'Retry-After': String(error.retryAfter) }
: {}
sendJson(
response,
status,
{ error: error instanceof Error ? error.message : 'Unable to process request.' },
headers,
)
} finally {
database.close()
}
}
export async function handleApiRequest(request, response, next) { export async function handleApiRequest(request, response, next) {
if (!request.url?.startsWith('/api/')) { if (!request.url?.startsWith('/api/')) {
next() next()
return return
} }
if (request.method === 'OPTIONS') {
sendCorsPreflight(request, response)
return
}
setCorsHeaders(response, request)
if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') { if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') {
sendBossImage(request, response) sendBossImage(request, response)
return return
@@ -2242,54 +2660,7 @@ export async function handleApiRequest(request, response, next) {
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
`).run() `).run()
if (request.url === '/api/auth/register' && request.method === 'POST') { if (await handleAuthApiRoute(database, request, response)) {
const payload = await readJson(request)
const result = registerAccount(database, request, payload)
sendJson(
response,
201,
{ account: result.account, profile: result.profile },
{ 'Set-Cookie': sessionCookie(result.token, request) },
)
return
}
if (request.url === '/api/auth/login' && request.method === 'POST') {
const payload = await readJson(request)
const result = loginAccount(database, request, payload)
sendJson(
response,
200,
{ account: result.account, profile: result.profile },
{ 'Set-Cookie': sessionCookie(result.token, request) },
)
return
}
if (request.url === '/api/auth/session' && request.method === 'GET') {
const session = currentSession(database, request)
if (!session) {
sendJson(response, 200, { account: null, profile: null })
return
}
sendJson(response, 200, {
account: { id: session.accountId, username: session.username },
profile: getProfile(database, session.characterId, session.accountId),
})
return
}
if (request.url === '/api/auth/logout' && request.method === 'POST') {
const token = parseCookies(request)[sessionCookieName]
if (token) {
database.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash(token))
}
sendJson(
response,
200,
{ ok: true },
{ 'Set-Cookie': sessionCookie('', request, 0) },
)
return return
} }
@@ -2401,6 +2772,16 @@ export async function handleApiRequest(request, response, next) {
return return
} }
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
if (itemUpgrade && request.method === 'POST') {
sendJson(
response,
200,
upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
)
return
}
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/) const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
if (encounterLootRoll && request.method === 'POST') { if (encounterLootRoll && request.method === 'POST') {
const payload = await readJson(request) const payload = await readJson(request)
+2782 -9
View File
File diff suppressed because it is too large Load Diff
+346 -205
View File
@@ -55,6 +55,7 @@ const MENU_ITEMS: Array<{
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty' const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
const SHOW_LEADERBOARDS = false const SHOW_LEADERBOARDS = false
const ACTIVITY_PAGE_SIZE = 4
function activityInitials(name: string) { function activityInitials(name: string) {
return name return name
@@ -81,13 +82,14 @@ function App() {
return Number.isFinite(saved) && saved > 0 ? saved : 1 return Number.isFinite(saved) && saved > 0 ? saved : 1
}) })
const [selectedDungeonId, setSelectedDungeonId] = useState(1) const [selectedDungeonId, setSelectedDungeonId] = useState(1)
const [selectedRaidId, setSelectedRaidId] = useState(2) const [selectedRaidId, setSelectedRaidId] = useState(20)
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon') const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve') const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter') const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability') const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon') const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
const [selectedPart, setSelectedPart] = useState(1) const [selectedMarathonMode, setSelectedMarathonMode] = useState(false)
const [activityPage, setActivityPage] = useState(0)
const [combatContentId, setCombatContentId] = useState(1) const [combatContentId, setCombatContentId] = useState(1)
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1') const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
const [showLoot, setShowLoot] = useState(false) const [showLoot, setShowLoot] = useState(false)
@@ -131,6 +133,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 +157,9 @@ function App() {
setScreen('menu') setScreen('menu')
setError('') setError('')
setServerMessage('') setServerMessage('')
window.requestAnimationFrame(() => {
focusFirstControl()
})
} }
async function signOut() { async function signOut() {
@@ -220,17 +232,18 @@ function App() {
const roguelikePool = profile.dungeons const roguelikePool = profile.dungeons
.filter((candidate) => candidate.contentType === roguelikeKind) .filter((candidate) => candidate.contentType === roguelikeKind)
.flatMap((candidate) => candidate.encounters) .flatMap((candidate) => candidate.encounters)
const startPart = selectedPart
return ( return (
<CombatScreen <CombatScreen
difficulty={difficulty} difficulty={difficulty}
dungeon={dungeon} dungeon={dungeon}
hardMode={false}
marathonMode={selectedMarathonMode && combatContentId > 0}
profile={profile} profile={profile}
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined} roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined} roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined} roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined} roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
startPart={startPart} startPart={1}
onExit={() => { onExit={() => {
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons') setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
}} }}
@@ -272,22 +285,54 @@ 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)
}
setSelectedMarathonMode(false)
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 activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE))
const currentActivityPage = Math.min(activityPage, activityPageCount - 1)
const pagedActivityOptions = activityOptions.slice(
currentActivityPage * ACTIVITY_PAGE_SIZE,
currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE,
)
const activityPageStart = activityOptions.length === 0
? 0
: currentActivityPage * ACTIVITY_PAGE_SIZE + 1
const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE)
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
const activity = activityOptions.find((candidate) => candidate.id === selectedActivityId)
?? activityOptions[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'
? profile.completedRaidPhases
: profile.completedDungeonParts
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
const parts = [
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true },
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
]
const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5
const cloudSync = getCloudSyncStatus() const cloudSync = getCloudSyncStatus()
const canShowCloudSync = account.id !== -1 && cloudSync.available const canShowCloudSync = account.id !== -1 && cloudSync.available
const lootPreviewEncounters = [...activity.encounters] const lootPreviewEncounters = [...activity.encounters]
@@ -297,7 +342,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"
@@ -394,84 +439,90 @@ function App() {
</div> </div>
{roguelikeVariant === 'pve' && ( {roguelikeVariant === 'pve' && (
<> <>
<div className="roguelike-option-panel"> <div className="roguelike-option-panel">
<div> <div>
<p className="eyebrow">Upgrade Timing</p> <p className="eyebrow">Run Type</p>
<h2>Buff Drafts</h2> <h2>PvE Roguelike</h2>
</div> </div>
<div className="roguelike-timing-row"> <div className="roguelike-timing-row">
<button <button
className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`} className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('encounter')} onClick={() => setRoguelikeKind('dungeon')}
type="button" type="button"
> >
Every Encounter Dungeon
</button> </button>
<button <button
className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`} className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('boss')} onClick={() => setRoguelikeKind('raid')}
type="button" type="button"
> >
Boss Only Raid
</button> </button>
</div> </div>
</div> </div>
<div className="roguelike-option-panel"> <div className="roguelike-option-panel">
<div> <div>
<p className="eyebrow">Upgrade Labels</p> <p className="eyebrow">Upgrade Timing</p>
<h2>Display Mode</h2> <h2>Buff Drafts</h2>
</div> </div>
<div className="roguelike-timing-row"> <div className="roguelike-timing-row">
<button <button
className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`} className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('ability')} onClick={() => setRoguelikeUpgradeTiming('encounter')}
type="button" type="button"
> >
Ability Names Every Encounter
</button> </button>
<button <button
className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`} className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('slot')} onClick={() => setRoguelikeUpgradeTiming('boss')}
type="button" type="button"
> >
Slot Names Boss Only
</button> </button>
</div> </div>
</div> </div>
<div className="roguelike-mode-grid"> <div className="roguelike-option-panel">
<button <div>
className="menu-card" <p className="eyebrow">Upgrade Labels</p>
onClick={() => { <h2>Display Mode</h2>
const baseDungeon = dungeonOptions[0] </div>
setRoguelikeKind('dungeon') <div className="roguelike-timing-row">
setCombatContentId(-1) <button
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1) className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`}
setSelectedPart(1) onClick={() => setRoguelikeAbilityLabelMode('ability')}
setScreen('combat') type="button"
}} >
type="button" Ability Names
> </button>
<span>D</span> <button
<strong>Dungeon Roguelike</strong> className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`}
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small> onClick={() => setRoguelikeAbilityLabelMode('slot')}
</button> type="button"
<button >
className="menu-card" Slot Names
onClick={() => { </button>
const baseRaid = raidOptions[0] </div>
setRoguelikeKind('raid') </div>
setCombatContentId(-2) <div className="menu-card pvp-queue-panel">
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101) <span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
setSelectedPart(1) <div>
setScreen('combat') <strong>{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'}</strong>
}} <small>
type="button" {roguelikeKind === 'raid'
> ? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
<span>R</span> : 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'}
<strong>Raid Roguelike</strong> </small>
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small> </div>
</button> <button
</div> className="text-button"
onClick={startPveRoguelike}
type="button"
>
Start Run
</button>
</div>
</> </>
)} )}
{roguelikeVariant === 'pvp' && ( {roguelikeVariant === 'pvp' && (
@@ -555,108 +606,196 @@ function App() {
)} )}
{(screen === 'dungeons' || screen === 'raids') && ( {(screen === 'dungeons' || screen === 'raids') && (
<section className="content-screen"> <section className="content-screen dungeon-run-screen">
<ScreenHeading <div className="dungeon-run-board">
eyebrow="Adventure" <div className="dungeon-run-main">
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'} <article className="run-summary-card dungeon-focus-card">
onBack={() => setScreen('menu')} <div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
/> {activityInitials(activity.name)}
<article className="dungeon-card"> </div>
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}> <div className="run-summary-copy">
{activityInitials(activity.name)} <p className="eyebrow">Selected Run</p>
<div className="run-title-row">
<h2>{activity.name}</h2>
<button className="back-button inline-back-button" onClick={() => setScreen('menu')} type="button">Back</button>
</div>
<p>{activity.description}</p>
<div className="tag-row">
<span>Level {activity.recommendedLevel}</span>
<span>{activity.partySize} Players</span>
<span>{selectedDifficulty.name}</span>
<span>iLvl {selectedDifficulty.droppedItemLevel}</span>
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
</div>
</div>
</article>
<section className="run-setup-panel dungeon-choice-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Pick Run</p>
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
</div>
{activityPageCount > 1 ? (
<div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
<button
disabled={currentActivityPage === 0}
onClick={() => setActivityPage((page) => Math.max(0, page - 1))}
type="button"
>
Prev
</button>
<span>{activityPageStart}-{activityPageEnd} of {activityOptions.length}</span>
<button
disabled={currentActivityPage >= activityPageCount - 1}
onClick={() => setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))}
type="button"
>
Next
</button>
</div>
) : (
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
)}
</div>
<div className="activity-card-grid dungeon-choice-grid">
{pagedActivityOptions.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> </div>
<div>
<p className="eyebrow">{activity.locationName}</p> <aside className="dungeon-setup-rail">
<h2>{activity.name}</h2> <section className="run-setup-panel tier-setup-panel">
<p>{activity.description}</p> <div className="run-setup-heading">
<div className="tag-row"> <div>
<span>Level {activity.recommendedLevel}</span> <p className="eyebrow">Item Level</p>
<span>{activity.partySize} Players</span> <h2>Tier</h2>
<span>{selectedDifficulty.name}</span> </div>
<span>Component Level {selectedDifficulty.droppedItemLevel}</span> <small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span> </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={() => {
setActivityPage(0)
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"
>
<strong>iLvl {difficulty.droppedItemLevel}</strong>
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
</button>
)
})}
</div>
</section>
<section className="run-setup-panel part-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Start</p>
<h2>Run</h2>
</div>
<small>
{difficultyLocked
? `Unlocks at level ${selectedDifficulty.unlockLevel}`
: 'Marathon keeps health and mana between boss kills.'}
</small>
</div>
<div className="part-picker">
<button
className="primary-button selected-part"
disabled={difficultyLocked}
onClick={() => {
setSelectedMarathonMode(false)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
Start Hunt
</button>
<button
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''}`}
disabled={difficultyLocked}
onClick={() => {
setSelectedMarathonMode(true)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
Marathon
</button>
</div>
</section>
<div className="difficulty-section compact-difficulty-section">
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
<div>
<strong>{selectedDifficulty.name}</strong>
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : selectedDifficulty.description}</small>
</div>
<dl>
<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>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
<div><dt>Loot</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
</dl>
</div>
</div> </div>
</div>
{activityOptions.length > 1 && ( <div className="loot-preview-section">
<label className="activity-select">
<span>{activity.contentType === 'raid' ? 'Raid' : 'Dungeon'}</span>
<select
value={activity.id}
onChange={(event) => {
const nextActivityId = Number(event.target.value)
const nextActivity = activityOptions.find((candidate) => candidate.id === nextActivityId)
if (screen === 'raids') setSelectedRaidId(nextActivityId)
else setSelectedDungeonId(nextActivityId)
if (nextActivity?.difficulties[0]) {
setSelectedDifficultyId(nextActivity.difficulties[0].id)
}
}}
>
{activityOptions.map((candidate) => (
<option key={candidate.id} value={candidate.id}>{candidate.name}</option>
))}
</select>
</label>
)}
<div className="part-buttons">
{parts.map((p) => (
<button
key={p.part}
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !p.unlocked}
onClick={() => {
setSelectedPart(p.part)
setCombatContentId(activity.id)
setScreen('combat')
}}
type="button"
>
{p.name}
</button>
))}
</div>
</article>
<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>
<strong>{selectedDifficulty.name}</strong>
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : selectedDifficulty.description}</small>
</div>
<dl>
<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>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
<div><dt>Components</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
</dl>
</div>
</div>
<div className="loot-preview-section">
<div className="equipment-heading toggle-heading"> <div className="equipment-heading toggle-heading">
<div> <div>
<p className="eyebrow">Encounter Rewards</p> <p className="eyebrow">Encounter Rewards</p>
@@ -682,7 +821,7 @@ function App() {
</label> </label>
</div> </div>
<p className="section-note"> <p className="section-note">
Bosses roll {lootChanceSlots} loot chances against the listed table, 1-3 materials each Bosses drop 1-3 boss coins from one loot roll
{activity.completionItemLevel {activity.completionItemLevel
? ` - full clear guarantees iLvl ${activity.completionItemLevel}` ? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
: ''} : ''}
@@ -702,7 +841,7 @@ function App() {
)} )}
<div> <div>
<strong>{encounter.enemyName}</strong> <strong>{encounter.enemyName}</strong>
<small>{loot.length > 0 ? `${lootChanceSlots} weighted chances, 1-3 each` : 'No component table'}</small> <small>{loot.length > 0 ? '1 loot roll, 1-3 coins' : 'No coin table'}</small>
</div> </div>
</div> </div>
<div className="loot-items"> <div className="loot-items">
@@ -723,9 +862,9 @@ function App() {
</div> </div>
</> </>
)} )}
</div> </div>
{SHOW_LEADERBOARDS && ( {SHOW_LEADERBOARDS && (
<div className="leaderboard-section"> <div className="leaderboard-section">
<div className="equipment-heading toggle-heading"> <div className="equipment-heading toggle-heading">
<div> <div>
<p className="eyebrow">Efficiency Rankings</p> <p className="eyebrow">Efficiency Rankings</p>
@@ -750,10 +889,10 @@ function App() {
</p> </p>
<div className="leaderboard-tabs"> <div className="leaderboard-tabs">
{([ {([
{ key: 'part_1', label: `${sectionName} 1` }, { key: 'part_1', label: 'Run' },
{ key: 'part_2', label: `${sectionName} 2` }, { key: 'part_2', label: 'Legacy 2' },
{ key: 'part_3', label: `${sectionName} 3` }, { key: 'part_3', label: 'Legacy 3' },
{ key: 'full_run', label: 'Full Run' }, { key: 'full_run', label: 'Legacy Full' },
] as const).map((tab) => ( ] as const).map((tab) => (
<button <button
key={tab.key} key={tab.key}
@@ -802,8 +941,10 @@ function App() {
</div> </div>
</> </>
)} )}
</div>
)}
</aside>
</div> </div>
)}
</section> </section>
)} )}
+1 -1
View File
@@ -183,7 +183,7 @@ function ItemsTab({ data, setData, setSaving, saving }: {
</label> </label>
<label>Rarity <label>Rarity
<select value={form.rarity ?? item.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}> <select value={form.rarity ?? item.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
{['common', 'uncommon', 'rare', 'epic'].map((r) => ( {['common', 'uncommon', 'rare', 'epic', 'legendary'].map((r) => (
<option key={r} value={r}>{r}</option> <option key={r} value={r}>{r}</option>
))} ))}
</select> </select>
File diff suppressed because it is too large Load Diff
+42 -3
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)
@@ -38,7 +40,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
function chooseClass(nextClass: GameClass) { function chooseClass(nextClass: GameClass) {
const starterAbilities = nextClass.spells const starterAbilities = nextClass.spells
.filter((ability) => ability.unlockLevel <= profile.character.level) .filter((ability) => ability.unlockLevel <= profile.character.level)
.slice(0, 5) .slice(0, 6)
.map((ability) => ability.id) .map((ability) => ability.id)
setClassId(nextClass.id) setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)]) setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
@@ -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}
/> />
+350 -102
View File
@@ -4,10 +4,12 @@ import {
craftItem, craftItem,
equipItem, equipItem,
loadProfile, loadProfile,
upgradeItem,
type CharacterProfile, type CharacterProfile,
type EquipmentSlot, type EquipmentSlot,
type Item, type Item,
} from '../profile' } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
const SLOT_LABELS: Record<EquipmentSlot, string> = { const SLOT_LABELS: Record<EquipmentSlot, string> = {
weapon: 'Weapon', weapon: 'Weapon',
@@ -23,16 +25,29 @@ 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')
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
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,
@@ -46,19 +61,38 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [equipping, setEquipping] = useState(false) const [equipping, setEquipping] = useState(false)
const [breakingDown, setBreakingDown] = useState(false) const [breakingDown, setBreakingDown] = useState(false)
const [crafting, setCrafting] = useState(false) const [crafting, setCrafting] = useState(false)
const [upgrading, setUpgrading] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false) const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment') const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
const [inventoryPage, setInventoryPage] = useState(0) const [inventoryPage, setInventoryPage] = useState(0)
const [recipePage, setRecipePage] = useState(0) const [recipePage, setRecipePage] = useState(0)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0) const scrollRef = useRef<number>(0)
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId) const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft) const craftableRecipes = profile.craftingRecipes.filter((recipe) =>
?? profile.craftingRecipes[0] DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
)
const firstRecipe = craftableRecipes.find((recipe) => recipe.canCraft)
?? craftableRecipes[0]
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>( const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
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
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
: false
const selectedItemRecipe = selectedItem
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
: undefined
const upgradeRecipe = selectedItem && selectedItemRecipe
? profile.craftingRecipes
.filter((recipe) =>
recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId
&& recipe.item.slot === selectedItem.slot
&& recipe.item.itemLevel > selectedItem.itemLevel,
)
.sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0]
: undefined
const equippedBySlot = useMemo( const equippedBySlot = useMemo(
() => new Map( () => new Map(
profile.inventory profile.inventory
@@ -92,12 +126,14 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all') const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
const [levelFilter, setLevelFilter] = useState<number | null>(null) const [levelFilter, setLevelFilter] = useState<number | null>(null)
const availableLevels = useMemo( const availableLevels = useMemo(
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a), () => [...new Set(profile.craftingRecipes
.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
[profile.craftingRecipes], [profile.craftingRecipes],
) )
const filteredRecipes = useMemo( const filteredRecipes = useMemo(
() => { () => {
let result = [...profile.craftingRecipes] let result = profile.craftingRecipes.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter) if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter) if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel) result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
@@ -105,6 +141,19 @@ 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
&& DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
).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),
@@ -126,12 +175,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
} }
@@ -189,6 +252,160 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
} }
} }
async function upgradeSelected() {
if (!selectedItem || !upgradeRecipe) return
saveScroll()
setUpgrading(true)
setMessage('')
try {
const updated = await upgradeItem(selectedItem.id)
onUpdated(updated)
setSelectedItemId(upgradeRecipe.item.id)
setMessage(`${selectedItem.name} upgraded to ${upgradeRecipe.item.name}.`)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to upgrade item.')
} finally {
setUpgrading(false)
}
}
function renderEquipmentActions() {
if (!selectedItem) {
return <p>Select an item to inspect it.</p>
}
if (selectedItem.slot === 'component') {
return <p className="component-note">Used in crafting.</p>
}
return (
<>
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button
className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
onClick={equipSelected}
type="button"
>
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button>
{upgradeRecipe && (
<button
className="primary-button"
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
onClick={upgradeSelected}
type="button"
>
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
</button>
)}
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button
className="breakdown-button"
disabled={equipping || breakingDown || upgrading}
onClick={breakdownSelected}
type="button"
>
{breakingDown
? 'Breaking Down...'
: selectedItem.quantity > 1
? 'Break Down Duplicate'
: 'Break Down'}
</button>
)}
</>
)
}
const workshopState = useMemo<DualScreenWorkshopState>(() => {
if (equipmentTab === 'crafting') {
if (!selectedRecipe) {
return {
mode: 'crafting',
title: 'Craft Output',
subtitle: 'No recipe selected',
items: [],
}
}
return {
mode: 'crafting',
title: selectedRecipe.item.name,
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
summary: selectedRecipe.item.description,
items: [
{
glyph: selectedRecipe.item.glyph,
title: 'Craft Output',
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
},
...selectedRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Item Level ${component.item.itemLevel}`,
status: `${component.owned}/${component.quantity}`,
})),
],
}
}
if (!selectedItem) {
return {
mode: 'equipment',
title: 'Equipment Detail',
subtitle: 'No item selected',
items: [],
}
}
return {
mode: 'equipment',
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
summary: selectedItem.description,
items: selectedItem.slot === 'component'
? [{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `Owned: ${selectedItem.quantity}`,
status: 'Component',
}]
: [
{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
},
...(comparisonItem && comparisonItem.id !== selectedItem.id
? [{
glyph: comparisonItem.glyph,
title: comparisonItem.name,
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
status: 'Currently Equipped',
}]
: [{
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
status: 'Comparison',
}]),
...(upgradeRecipe
? [
{
glyph: upgradeRecipe.item.glyph,
title: `Upgrade to ${upgradeRecipe.item.name}`,
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
},
...upgradeRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Required for upgrade`,
status: `${component.owned}/${component.quantity}`,
})),
]
: []),
],
}
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
const content = ( const content = (
<> <>
{!embedded && ( {!embedded && (
@@ -215,22 +432,24 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} /> <GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div> </div>
<nav className="equipment-tabs"> {showModeTabs && (
<button <nav className="equipment-tabs">
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`} <button
onClick={() => setEquipmentTab('equipment')} className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
type="button" onClick={() => setEquipmentTab('equipment')}
> type="button"
Equipment >
</button> Equipment
<button </button>
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`} <button
onClick={() => setEquipmentTab('crafting')} className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
type="button" onClick={() => setEquipmentTab('crafting')}
> type="button"
Crafting >
</button> Crafting
</nav> </button>
</nav>
)}
{equipmentTab === 'equipment' ? ( {equipmentTab === 'equipment' ? (
<> <>
@@ -239,9 +458,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
selectedItem.slot === 'component' ? ( selectedItem.slot === 'component' ? (
<> <>
<ItemDetail title="Crafting Component" item={selectedItem} /> <ItemDetail title="Crafting Component" item={selectedItem} />
<div className="equip-action">
<p className="component-note">Used in crafting.</p>
</div>
</> </>
) : ( ) : (
<> <>
@@ -255,31 +471,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2> <h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div> </div>
)} )}
<div className="equip-action">
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button
className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown}
onClick={equipSelected}
type="button"
>
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button>
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button
className="breakdown-button"
disabled={equipping || breakingDown}
onClick={breakdownSelected}
type="button"
>
{breakingDown
? 'Breaking Down...'
: selectedItem.quantity > 1
? 'Break Down Duplicate'
: 'Break Down'}
</button>
)}
</div>
</> </>
) )
) : ( ) : (
@@ -287,6 +478,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" />
@@ -382,43 +577,82 @@ 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"> <div className="crafting-layout">
<select <aside className="crafting-filters">
className="filter-select" <div>
value={slotFilter} <p className="eyebrow">Slot</p>
onChange={(e) => { <div className="crafting-filter-grid">
setSlotFilter(e.target.value as EquipmentSlot | 'all') <button
setRecipePage(0) className={slotFilter === 'all' ? 'active' : ''}
}} onClick={() => {
> setSlotFilter('all')
<option value="all">All Slots</option> setRecipePage(0)
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => ( }}
<option key={slot} value={slot}>{label}</option> type="button"
))} >
</select> <strong>All</strong>
<select <span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
className="filter-select" </button>
value={levelFilter ?? ''} {CRAFTING_FILTER_SLOTS.map((slot) => (
onChange={(e) => { <button
setLevelFilter(e.target.value === '' ? null : Number(e.target.value)) className={slotFilter === slot ? 'active' : ''}
setRecipePage(0) disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
}} key={slot}
> onClick={() => {
<option value="">All Levels</option> setSlotFilter(slot)
{availableLevels.map((level) => ( setRecipePage(0)
<option key={level} value={level}>Item Level {level}</option> }}
))} type="button"
</select> >
</div> <strong>{SLOT_LABELS[slot]}</strong>
{filteredRecipes.length === 0 && ( <span>{slotRecipeCounts.get(slot) ?? 0}</span>
<p className="inventory-empty">No crafting recipes match filters.</p> </button>
)} ))}
{filteredRecipes.length > 0 && ( </div>
<div className="crafting-layout"> </div>
<div className="crafting-list"> <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">
{recipePageItems.map((recipe) => ( {recipePageItems.map((recipe) => (
<button <button
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`} className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
@@ -434,9 +668,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}`}
@@ -446,10 +684,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
@@ -462,22 +716,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>
)} )}
</div> </section>
)} </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>
@@ -513,11 +761,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>
) )
+286 -60
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'
@@ -12,8 +22,10 @@ import {
type DualScreenCombatState, type DualScreenCombatState,
} from '../dualScreen' } from '../dualScreen'
import { import {
loadPvpRoguelikeCheckpoint,
randomCpuDifficulty, randomCpuDifficulty,
recordCpuPvpLeaderboard, recordCpuPvpLeaderboard,
recordPvpRoguelikeCheckpoint,
type CpuDifficulty, type CpuDifficulty,
type PvpContentType, type PvpContentType,
} from '../pvpRoguelike' } from '../pvpRoguelike'
@@ -29,9 +41,10 @@ type BossMechanic =
type PvpEncounter = DungeonEncounter & { type PvpEncounter = DungeonEncounter & {
bossMechanics?: BossMechanic[] bossMechanics?: BossMechanic[]
sourceEncounterId?: number
} }
type SlotKey = '1' | '2' | '3' | '4' | '5' type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
type AbilityLabelMode = 'ability' | 'slot' type AbilityLabelMode = 'ability' | 'slot'
type SelfBuffId = type SelfBuffId =
@@ -75,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',
@@ -116,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
} }
@@ -127,7 +164,7 @@ function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode)
} }
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> { function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => { const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
const label = slotLabel(slot, spells, labelMode) const label = slotLabel(slot, spells, labelMode)
return [ return [
{ {
@@ -156,7 +193,7 @@ function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Arr
} }
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> { function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => { const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
const label = slotLabel(slot, spells, labelMode) const label = slotLabel(slot, spells, labelMode)
return [ return [
{ {
@@ -214,13 +251,13 @@ function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) {
return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced') return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced')
} }
function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) { function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1 const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1
return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs)) return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs) * multiplier)
} }
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) { function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
return clamp(member.health + healAmount(member, amount, debuffs), 0, effectiveMaxHealth(member)) return clamp(member.health + healAmount(member, amount, debuffs, multiplier), 0, effectiveMaxHealth(member))
} }
function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) { function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) {
@@ -261,6 +298,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
const isBoss = index === 2 const isBoss = index === 2
return { return {
...encounter, ...encounter,
sourceEncounterId: encounter.id,
id: 910000 + stage * 10 + index, id: 910000 + stage * 10 + index,
sequence: (stage - 1) * 3 + index + 1, sequence: (stage - 1) * 3 + index + 1,
isBoss, isBoss,
@@ -296,7 +334,7 @@ function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
if (buff.id === 'fifth-cast-free') return 8 if (buff.id === 'fifth-cast-free') return 8
if (buff.id === 'group-heal-boost') return 8 if (buff.id === 'group-heal-boost') return 8
if (buff.id === 'shield-boost') return 6 if (buff.id === 'shield-boost') return 6
const slot = buff.id.match(/slot([1-5])/i)?.[1] as SlotKey | undefined const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
const spell = spells.find((candidate) => candidate.key === slot) const spell = spells.find((candidate) => candidate.key === slot)
if (!spell) return 5 if (!spell) return 5
if (buff.id.endsWith('extra-target')) { if (buff.id.endsWith('extra-target')) {
@@ -370,7 +408,7 @@ export function PvPRoguelikeScreen({
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)! const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
const starterSpells = useMemo(() => gameClass.spells const starterSpells = useMemo(() => gameClass.spells
.filter((spell) => spell.unlockLevel === 1) .filter((spell) => spell.unlockLevel === 1)
.slice(0, 5) .slice(0, 6)
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells]) .map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
const [abilityLabelMode] = useState<AbilityLabelMode>('ability') const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
const selfBuffChoicesCatalog = useMemo( const selfBuffChoicesCatalog = useMemo(
@@ -381,6 +419,10 @@ export function PvPRoguelikeScreen({
() => buildOpponentDebuffChoices(starterSpells, abilityLabelMode), () => buildOpponentDebuffChoices(starterSpells, abilityLabelMode),
[abilityLabelMode, starterSpells], [abilityLabelMode, starterSpells],
) )
const [checkpointStage, setCheckpointStage] = useState(() =>
loadPvpRoguelikeCheckpoint(profile.character.id, contentType),
)
const [startStage, setStartStage] = useState(checkpointStage)
const maxResource = gameClass.maxResource const maxResource = gameClass.maxResource
const partyTemplate = useMemo( const partyTemplate = useMemo(
() => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({ () => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
@@ -397,17 +439,20 @@ export function PvPRoguelikeScreen({
[contentType], [contentType],
) )
const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing') const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
const [stage, setStage] = useState(1) const [stage, setStage] = useState(startStage)
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, 1, contentType)) const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
const [encounterIndex, setEncounterIndex] = useState(0) const [encounterIndex, setEncounterIndex] = useState(0)
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource)) const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource)) const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
const [selectedId, setSelectedId] = useState(partyTemplate[0].id) const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const selectedIdRef = useRef(partyTemplate[0].id)
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
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[]>([])
@@ -425,6 +470,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]
@@ -437,6 +484,14 @@ export function PvPRoguelikeScreen({
? Math.max(encountersCleared, encounterIndex + 1) ? Math.max(encountersCleared, encounterIndex + 1)
: encountersCleared : encountersCleared
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1] const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
const activeSpellEffects = useMemo(
() => new Set(
gameClass.talents
.filter((talent) => talent.rank > 0)
.map((talent) => talent.effectType),
),
[gameClass.talents],
)
const playerDone = playerSide.enemyHealth <= 0 const playerDone = playerSide.enemyHealth <= 0
const cpuDone = cpuSide.enemyHealth <= 0 const cpuDone = cpuSide.enemyHealth <= 0
const playerAlive = playerSide.party.some((member) => member.health > 0) const playerAlive = playerSide.party.some((member) => member.health > 0)
@@ -451,6 +506,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))
}, []) }, [])
@@ -464,9 +525,21 @@ export function PvPRoguelikeScreen({
}, 900) }, 900)
}, []) }, [])
useEffect(() => {
if (queuedMatchRef.current) return
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
setCheckpointStage(loadedCheckpoint)
setStartStage(loadedCheckpoint)
}, [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)
const rewardEncounter = encounters[encounterIndexValue]
completeRoguelike( completeRoguelike(
rewardDungeon.id, rewardDungeon.id,
rewardDifficulty.id, rewardDifficulty.id,
@@ -476,18 +549,40 @@ export function PvPRoguelikeScreen({
{ {
bossesCleared: 1, bossesCleared: 1,
experienceMode: 'pvp-boss-quarter-level', experienceMode: 'pvp-boss-quarter-level',
lootSourceEncounterId: rewardEncounter?.sourceEncounterId,
roguelikeStage: stage,
}, },
) )
.then((result) => { .then((result) => {
setReward(result) setReward(result)
setRunSummary((current) => {
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
return {
bossesKilled: current.bossesKilled + 1,
experienceGained: current.experienceGained + result.experienceGained,
previousLevel: current.previousLevel ?? result.previousLevel,
newLevel: result.newLevel,
levelsGained: current.levelsGained + result.levelsGained,
talentPointsGained: current.talentPointsGained + result.talentPointsGained,
unlockedAbilities: Array.from(unlockedById.values()),
loot: result.bonusItem ? [...current.loot, result.bonusItem] : current.loot,
}
})
onProfileUpdated(result.profile) onProfileUpdated(result.profile)
if (result.bonusItem) {
addLog(
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
'loot',
)
}
}) })
.catch((reason: unknown) => { .catch((reason: unknown) => {
setRewardError( setRewardError(
reason instanceof Error ? reason.message : 'Unable to award roguelike experience.', reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
) )
}) })
}, [elapsedTicks, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id]) }, [addLog, elapsedTicks, encounters, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id, stage])
const finishRoguelikeRun = useCallback(() => { const finishRoguelikeRun = useCallback(() => {
if (rewardClaimedRef.current) return if (rewardClaimedRef.current) return
@@ -509,8 +604,9 @@ export function PvPRoguelikeScreen({
: null) : null)
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
useEffect(() => { const startMatch = useCallback((nextStartStage?: number) => {
const firstSegment = buildEncounterSegment(encounterPool, 1, contentType) const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
const firstEncounter = firstSegment[0] const firstEncounter = firstSegment[0]
const basePlayer = starterSide(partyTemplate, maxResource) const basePlayer = starterSide(partyTemplate, maxResource)
const baseCpu = starterSide(cpuPartyTemplate, maxResource) const baseCpu = starterSide(cpuPartyTemplate, maxResource)
@@ -520,15 +616,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(1) setCheckpointStage(matchStartStage)
setStartStage(matchStartStage)
setStage(matchStartStage)
setElapsedTicks(0) setElapsedTicks(0)
setStatus('queueing') setStatus('queueing')
setPlayerSide(basePlayer) setPlayerSide(basePlayer)
setCpuSide(baseCpu) setCpuSide(baseCpu)
setSelectedId(partyTemplate[0].id) setSelectedTargetId(partyTemplate[0].id)
setPlayerBuffChoices([]) setPlayerBuffChoices([])
setPlayerDebuffChoices([]) setPlayerDebuffChoices([])
setSelectedBuff(null) setSelectedBuff(null)
@@ -537,6 +636,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([])
@@ -546,26 +646,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 immediately.`) setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
setCpuDifficulty(randomCpu) setCpuDifficulty(randomCpu)
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters immediately.`, tone: 'system' }]) setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
setStatus('playing') setStatus('playing')
addLog(`Round 1 begins against CPU ${randomCpu}.`, 'system') addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
}, 500) }, 500)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
} }
setQueueMessage('Searching queue. No player found yet.') setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
setLog([{ id: 1, text: 'Searching queue. No player found yet.', tone: 'system' }]) setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const randomCpu = randomCpuDifficulty() const randomCpu = randomCpuDifficulty()
setCpuDifficulty(randomCpu) setCpuDifficulty(randomCpu)
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`) setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
setStatus('playing') setStatus('playing')
addLog(`No queued player found. CPU ${randomCpu} steps in.`, 'system') addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
}, 1400) }, 1400)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate]) }, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
useEffect(() => startMatch(), [startMatch])
const applySpell = useCallback(( const applySpell = useCallback((
current: SideState, current: SideState,
@@ -584,10 +686,21 @@ export function PvPRoguelikeScreen({
const extraTarget = (blockedIds: string[]) => livingTargets const extraTarget = (blockedIds: string[]) => livingTargets
.filter((member) => !blockedIds.includes(member.id)) .filter((member) => !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 hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
const renewEffect = starterSpells.find((candidate) => candidate.kind === 'hot')
const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
const radianceEffect = starterSpells.find((candidate) => candidate.kind === 'group')
const healingMultiplier = (member: PartyMember) =>
hasSpellEffect('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
const directTargets = new Set([targetId]) const directTargets = new Set([targetId])
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') {
@@ -603,21 +716,45 @@ export function PvPRoguelikeScreen({
const extra = extraTarget([...directTargets]) const extra = extraTarget([...directTargets])
if (extra) directTargets.add(extra.id) if (extra) directTargets.add(extra.id)
} }
if (spell.kind === 'direct' && hasSpellEffect('mend_applies_renew') && renewEffect) {
directTargets.forEach((id) => hotTargets.add(id))
}
if (spell.kind === 'direct' && hasSpellEffect('mend_applies_shield') && shieldEffect) {
directTargets.forEach((id) => shieldTargets.add(id))
}
if (spell.kind === 'shield' && hasSpellEffect('shield_applies_renew') && renewEffect) {
shieldTargets.forEach((id) => hotTargets.add(id))
}
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, healingMultiplier(member))
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
return { ...member, health: nextHealth } const nextShield = hasSpellEffect('radiance_applies_shield')
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3))
: member.shield
return {
...member,
health: nextHealth,
shield: nextShield,
hotTicks: hasSpellEffect('radiance_applies_renew') && renewEffect
? Math.max(member.hotTicks, 3)
: member.hotTicks,
}
} }
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
if (spell.kind === 'shield') { if (spell.kind === 'shield') {
const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost'))) const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost')))
return { ...member, shield: Math.max(member.shield, shieldPower) } return {
...member,
shield: Math.max(member.shield, shieldPower),
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
}
} }
if (spell.kind === 'cleanse') { if (spell.kind === 'cleanse') {
const nextHealth = healMember(member, spell.power, debuffs) const nextHealth = healMember(member, spell.power, debuffs, healingMultiplier(member))
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health)) addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
return { return {
...member, ...member,
@@ -629,11 +766,17 @@ export function PvPRoguelikeScreen({
healingReductionTicks: undefined, healingReductionTicks: undefined,
} }
} }
const nextHealth = directTargets.has(member.id) ? healMember(member, spell.power, debuffs) : member.health const nextHealth = directTargets.has(member.id)
? healMember(member, spell.power, debuffs, healingMultiplier(member))
: member.health
if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health) if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health)
const nextShield = shieldTargets.has(member.id) && spell.kind === 'direct' && hasSpellEffect('mend_applies_shield')
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5))
: member.shield
return { return {
...member, ...member,
health: nextHealth, health: nextHealth,
shield: nextShield,
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
} }
}) })
@@ -647,46 +790,51 @@ export function PvPRoguelikeScreen({
: current.castsTowardFree + 1 : current.castsTowardFree + 1
: current.castsTowardFree : current.castsTowardFree
const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5 const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5
const nextCooldowns = {
...current.cooldowns,
}
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2)
}
nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, buffs, debuffs)
const nextState: SideState = { const nextState: SideState = {
...current, ...current,
party: nextParty, party: nextParty,
resource: current.resource - effectiveCost, resource: current.resource - effectiveCost,
cooldowns: { cooldowns: nextCooldowns,
...current.cooldowns,
[spell.id]: spell.cooldown * cooldownMultiplier(spell, buffs, debuffs),
},
castsTowardFree: nextCastsTowardFree, castsTowardFree: nextCastsTowardFree,
freeCastReady: gainedFreeCast || nextFreeCastReady, freeCastReady: gainedFreeCast || nextFreeCastReady,
} }
setCurrent(nextState) setCurrent(nextState)
return true return true
}, [addFloatingHeal]) }, [activeSpellEffects, addFloatingHeal, starterSpells])
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)
@@ -713,14 +861,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
@@ -767,10 +915,15 @@ 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 hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
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
@@ -778,8 +931,12 @@ export function PvPRoguelikeScreen({
: member.poisonStacks ?? 0 : member.poisonStacks ?? 0
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3 if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
damage = Math.round(damage * damageMultiplier) damage = Math.round(damage * damageMultiplier)
if (member.shield > 0 && hasSpellEffect('shielded_damage_reduction')) {
damage = Math.round(damage * 0.8)
}
const absorbed = Math.min(member.shield, damage) const absorbed = Math.min(member.shield, damage)
const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs) : 0 const healingMultiplier = member.shield > 0 && hasSpellEffect('shielded_healing_bonus') ? 1.2 : 1
const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs, healingMultiplier) : 0
if (healing > 0) addFloatingHeal(sideName, member.id, healing) if (healing > 0) addFloatingHeal(sideName, member.id, healing)
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
? 14 ? 14
@@ -819,9 +976,9 @@ 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]) }, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
const beginUpgradePhase = useCallback(() => { const beginUpgradePhase = useCallback(() => {
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3)) setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
@@ -841,7 +998,18 @@ export function PvPRoguelikeScreen({
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) { if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
playerClearedEncounterRef.current = encounterIndex playerClearedEncounterRef.current = encounterIndex
setEncountersCleared((value) => value + 1) setEncountersCleared((value) => value + 1)
if (encounter.isBoss) awardBossReward(encounterIndex) if (encounter.isBoss) {
awardBossReward(encounterIndex)
const nextCheckpoint = recordPvpRoguelikeCheckpoint(
profile.character.id,
contentType,
stage,
)
if (nextCheckpoint > checkpointStage) {
setCheckpointStage(nextCheckpoint)
addLog(`Stage ${nextCheckpoint} checkpoint unlocked.`, 'loot')
}
}
} }
playerRef.current = nextPlayer playerRef.current = nextPlayer
cpuRef.current = nextCpu cpuRef.current = nextCpu
@@ -861,12 +1029,18 @@ 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()
} }
}, TICK_MS) }, TICK_MS / speedMultiplier)
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, status]) }, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, speedMultiplier, stage, status])
useEffect(() => { useEffect(() => {
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
@@ -921,10 +1095,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
@@ -973,9 +1154,13 @@ 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 === 'toggleSpeed') {
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
return
}
if (action === 'pause' || action === 'back') { if (action === 'pause' || action === 'back') {
if (status === 'playing') setPaused((value) => !value) if (status === 'playing') setPaused((value) => !value)
return return
@@ -1002,9 +1187,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
@@ -1047,6 +1232,7 @@ export function PvPRoguelikeScreen({
directPartyTargeting, directPartyTargeting,
paused, paused,
targetGroup, targetGroup,
speedMultiplier,
}), [ }), [
bindings, bindings,
controllerIconStyle, controllerIconStyle,
@@ -1071,6 +1257,7 @@ export function PvPRoguelikeScreen({
playerSide.party, playerSide.party,
playerSide.resource, playerSide.resource,
selectedId, selectedId,
speedMultiplier,
stage, stage,
starterSpells, starterSpells,
status, status,
@@ -1094,7 +1281,7 @@ export function PvPRoguelikeScreen({
{dualScreenEnabled && status !== 'queueing' && ( {dualScreenEnabled && status !== 'queueing' && (
<DualScreenTopCombat <DualScreenTopCombat
state={dualScreenState} state={dualScreenState}
onSelectTarget={setSelectedId} onSelectTarget={setSelectedTargetId}
/> />
)} )}
@@ -1109,6 +1296,7 @@ export function PvPRoguelikeScreen({
<div className="resource-row pvp-resource-row"> <div className="resource-row pvp-resource-row">
<div className="pvp-resource-wrap"> <div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span> <span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div> <div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
</div> </div>
</div> </div>
@@ -1118,7 +1306,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">
@@ -1317,9 +1505,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 && (
@@ -1334,6 +1552,13 @@ export function PvPRoguelikeScreen({
Ability Unlocked: {ability.name} Ability Unlocked: {ability.name}
</p> </p>
))} ))}
{reward.bonusItem && (
<p className="ability-unlock">
<span>{reward.bonusItem.glyph}</span>
{reward.bonusItem.name} x{reward.bonusItem.quantity}
{reward.bonusItem.duplicate ? ` (owned x${reward.bonusItem.quantityAfter})` : ''}
</p>
)}
</> </>
)} )}
</div> </div>
@@ -1351,6 +1576,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>
+205 -117
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
allocateTalent, allocateTalent,
resetTalents, resetTalents,
type CharacterProfile, type CharacterProfile,
type Talent, type Talent,
} from '../profile' } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
type Props = { type Props = {
profile: CharacterProfile profile: CharacterProfile
@@ -13,199 +14,286 @@ type Props = {
embedded?: boolean embedded?: boolean
} }
const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const
const EFFECT_CLASS_ID = 1
const EFFECTS_PER_PAGE = 8
const EFFECT_SOURCE_LABELS: Record<string, string> = {
mend: 'Mend',
radiance: 'Radiance',
shield: 'Shield',
}
function effectSource(effectType: string) {
if (effectType.startsWith('mend_')) return 'mend'
if (effectType.startsWith('radiance_')) return 'radiance'
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'shield'
return effectType
}
function effectCapacity(level: number) {
return EFFECT_SLOT_LEVELS.filter((slotLevel) => level >= slotLevel).length
}
function activeEffects(talents: Talent[]) {
return talents.filter((talent) => talent.rank > 0)
}
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) { export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
const { enabled: dualScreenEnabled } = useDualScreen()
const [busyTalentId, setBusyTalentId] = useState<number | null>(null) const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
const [talentPage, setTalentPage] = useState(0)
const [resetting, setResetting] = useState(false) const [resetting, setResetting] = useState(false)
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
const [effectPage, setEffectPage] = useState(0)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0) const scrollRef = useRef<number>(0)
const gameClass = profile.classes.find( const gameClass = profile.classes.find(
(candidate) => candidate.id === profile.character.classId, (candidate) => candidate.id === profile.character.classId,
)! )!
const classPointsSpent = gameClass.talents.reduce( const isEffectClass = gameClass.id === EFFECT_CLASS_ID
(total, talent) => total + talent.rank, const capacity = isEffectClass ? effectCapacity(profile.character.level) : 0
0, const selectedEffects = activeEffects(gameClass.talents)
const selectedTalent = gameClass.talents.find((talent) => talent.id === selectedTalentId)
?? selectedEffects[0]
?? gameClass.talents[0]
?? null
const effectPageCount = Math.max(1, Math.ceil(gameClass.talents.length / EFFECTS_PER_PAGE))
const visibleTalents = gameClass.talents.slice(
effectPage * EFFECTS_PER_PAGE,
effectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE,
) )
const tiers = Array.from(
new Set(gameClass.talents.map((talent) => talent.tier)),
).sort((a, b) => a - b)
const tierPages = Array.from(
{ length: Math.ceil(tiers.length / 2) },
(_, index) => tiers.slice(index * 2, index * 2 + 2),
)
const visibleTiers = tierPages[talentPage] ?? tierPages[0] ?? []
useEffect(() => { useEffect(() => {
window.scrollTo(0, scrollRef.current) window.scrollTo(0, scrollRef.current)
}, [profile]) }, [profile])
useEffect(() => {
if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return
setSelectedTalentId(selectedTalent?.id ?? null)
}, [gameClass.talents, selectedTalent?.id, selectedTalentId])
useEffect(() => {
setEffectPage((page) => Math.min(page, effectPageCount - 1))
}, [effectPageCount])
function saveScroll() { function saveScroll() {
scrollRef.current = window.scrollY scrollRef.current = window.scrollY
} }
function lowerTierPoints(talent: Talent) {
return gameClass.talents
.filter((candidate) => candidate.tier < talent.tier)
.reduce((total, candidate) => total + candidate.rank, 0)
}
function lockReason(talent: Talent) { function lockReason(talent: Talent) {
if (talent.rank >= talent.maxRank) return 'Maximum rank' if (!isEffectClass) return 'Coming soon'
if (talent.rank > 0) return ''
const requiredTierPoints = (talent.tier - 1) * 5 const source = effectSource(talent.effectType)
if (lowerTierPoints(talent) < requiredTierPoints) { const sourceConflict = selectedEffects.find((effect) => effectSource(effect.effectType) === source)
return `Requires ${requiredTierPoints} earlier-tier points` if (sourceConflict) return `${EFFECT_SOURCE_LABELS[source] ?? source} already selected`
if (capacity <= 0) return 'Unlocks at level 5'
if (selectedEffects.length >= capacity) {
return `Active slots full (${capacity}/${capacity})`
} }
if (talent.prerequisiteTalentId) {
const prerequisite = gameClass.talents.find(
(candidate) => candidate.id === talent.prerequisiteTalentId,
)
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
return `Requires ${talent.prerequisiteName} rank ${talent.prerequisiteRank}`
}
}
if (profile.character.talentPoints <= 0) return 'No points available'
return '' return ''
} }
async function purchaseRank(talent: Talent) { async function toggleEffect(talent: Talent) {
saveScroll() saveScroll()
setBusyTalentId(talent.id) setBusyTalentId(talent.id)
setMessage('') setMessage('')
try { try {
const updated = await allocateTalent(talent.id) const updated = await allocateTalent(talent.id)
onUpdated(updated) onUpdated(updated)
setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`) setSelectedTalentId(talent.id)
setMessage(talent.rank > 0 ? `${talent.name} removed.` : `${talent.name} activated.`)
} catch (reason) { } catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.') setMessage(reason instanceof Error ? reason.message : 'Unable to update spell effect.')
} finally { } finally {
setBusyTalentId(null) setBusyTalentId(null)
} }
} }
async function refundTree() { async function clearEffects() {
saveScroll() saveScroll()
setResetting(true) setResetting(true)
setMessage('') setMessage('')
try { try {
const updated = await resetTalents() const updated = await resetTalents()
onUpdated(updated) onUpdated(updated)
setMessage('All points in this talent tree were refunded.') setMessage('Spell effects cleared.')
} catch (reason) { } catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.') setMessage(reason instanceof Error ? reason.message : 'Unable to clear spell effects.')
} finally { } finally {
setResetting(false) setResetting(false)
} }
} }
const workshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (!isEffectClass) return null
return {
mode: 'talents',
title: 'Spell Effects',
subtitle: `${selectedEffects.length}/${capacity} active`,
summary: selectedTalent
? `${selectedTalent.name}: ${selectedTalent.description}`
: 'Choose effects to modify your spells.',
items: gameClass.talents.map((talent) => ({
glyph: talent.glyph,
title: talent.name,
meta: talent.rank > 0 ? 'Active' : lockReason(talent) || 'Available',
detail: talent.description,
status: talent.rank > 0 ? 'Selected' : '',
})),
}
}, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent])
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
const content = ( const content = (
<> <>
{!embedded && ( {!embedded && (
<div className="screen-heading"> <div className="screen-heading">
<div> <div>
<p className="eyebrow">Character Growth</p> <p className="eyebrow">Character Growth</p>
<h1>Talents</h1> <h1>Spell Effects</h1>
</div> </div>
<button className="back-button" onClick={onBack} type="button">Back</button> <button className="back-button" onClick={onBack} type="button">Back</button>
</div> </div>
)} )}
<div className="talent-toolbar"> <div className="talent-toolbar spell-effect-toolbar">
<div className="talent-class-summary"> <div className="talent-class-summary">
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}> <span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
{gameClass.name[0]} {gameClass.name[0]}
</span> </span>
<div> <div>
<p className="eyebrow">{gameClass.name} Tree</p> <p className="eyebrow">{gameClass.name} Effects</p>
<h2>Shape Your Healing Style</h2> <h2>Modify Your Spells</h2>
</div> </div>
</div> </div>
<div className="talent-points"> <div className="talent-points">
<strong>{profile.character.talentPoints}</strong> <strong>{selectedEffects.length}/{capacity}</strong>
<span>Available</span> <span>Active</span>
<small>{classPointsSpent} spent in this tree</small> <small>Slots unlock at levels 5, 10, 15, 20</small>
</div> </div>
</div> </div>
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages"> {!isEffectClass ? (
{tierPages.map((pageTiers, index) => ( <div className="talent-empty-state">
<button <h2>Spell effects coming soon for {gameClass.name}.</h2>
aria-selected={talentPage === index} <p>This replacement system starts with the first class.</p>
className={talentPage === index ? 'active' : ''} </div>
key={pageTiers.join('-')} ) : (
onClick={() => setTalentPage(index)} <div className="spell-effect-layout">
role="tab" <section className="effect-slots-panel">
type="button" <p className="eyebrow">Active Slots</p>
> {EFFECT_SLOT_LEVELS.map((level, index) => {
Tiers {pageTiers[0]}-{pageTiers[pageTiers.length - 1]} const effect = selectedEffects[index]
</button> const unlocked = profile.character.level >= level
))} return (
</nav> <button
className={`effect-slot ${effect ? 'filled' : ''} ${unlocked ? '' : 'locked'}`}
disabled={!effect}
key={level}
onClick={() => effect && setSelectedTalentId(effect.id)}
type="button"
>
<span>Lv {level}</span>
<strong>{effect?.name ?? (unlocked ? 'Empty Slot' : 'Locked')}</strong>
<small>{effect?.description ?? (unlocked ? 'Choose an effect from the pool.' : `Reach level ${level}.`)}</small>
</button>
)
})}
</section>
<div className="talent-tree"> <section className="effect-pool-panel">
{visibleTiers.map((tier) => { <div className="effect-panel-heading">
const requiredPoints = (tier - 1) * 5 <div>
return ( <p className="eyebrow">Effect Pool</p>
<section className="talent-tier" key={tier}> <h2>Choose and Swap</h2>
<div className="tier-label">
<span>Tier {tier}</span>
<small>
{tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
</small>
</div> </div>
<div className="tier-talents"> <span>{selectedEffects.length}/{capacity} active</span>
{gameClass.talents </div>
.filter((talent) => talent.tier === tier) <div className="selected-effect-strip">
.sort((a, b) => a.branch - b.branch) <div>
.map((talent) => { <p className="eyebrow">Selected Effect</p>
const reason = lockReason(talent) {selectedTalent ? (
const isBusy = busyTalentId === talent.id <>
return ( <strong>{selectedTalent.name}</strong>
<article <small>{selectedTalent.description}</small>
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`} </>
key={talent.id} ) : (
style={{ gridColumn: talent.branch }} <small>No effect selected.</small>
> )}
<div className="talent-node-header">
<span>{talent.glyph}</span>
<div>
<strong>{talent.name}</strong>
<small>Rank {talent.rank}/{talent.maxRank}</small>
</div>
</div>
<p>{talent.description}</p>
<div className="rank-pips">
{Array.from({ length: talent.maxRank }, (_, index) => (
<i className={index < talent.rank ? 'filled' : ''} key={index} />
))}
</div>
<button
disabled={Boolean(reason) || isBusy}
onClick={() => purchaseRank(talent)}
type="button"
>
{isBusy ? 'Saving...' : reason || 'Add Rank'}
</button>
</article>
)
})}
</div> </div>
</section> {selectedTalent && (
) <button
})} className="primary-button"
</div> disabled={Boolean(lockReason(selectedTalent)) || busyTalentId === selectedTalent.id}
onClick={() => toggleEffect(selectedTalent)}
type="button"
>
{busyTalentId === selectedTalent.id
? 'Saving...'
: selectedTalent.rank > 0
? 'Remove'
: 'Activate'}
</button>
)}
</div>
<div className="effect-pool">
{visibleTalents.map((talent) => {
const reason = lockReason(talent)
const active = talent.rank > 0
const selected = selectedTalent?.id === talent.id
const isBusy = busyTalentId === talent.id
return (
<button
className={`${active ? 'active' : ''} ${selected ? 'selected' : ''}`}
disabled={Boolean(reason) || isBusy}
key={talent.id}
onClick={() => {
setSelectedTalentId(talent.id)
void toggleEffect(talent)
}}
type="button"
>
<span>{talent.glyph}</span>
<div>
<strong>{talent.name}</strong>
<small>{EFFECT_SOURCE_LABELS[effectSource(talent.effectType)] ?? 'Spell'}</small>
</div>
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
</button>
)
})}
</div>
{effectPageCount > 1 && (
<div className="effect-pager">
<button
disabled={effectPage === 0}
onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
type="button"
>
Prev
</button>
<span>{effectPage + 1}/{effectPageCount}</span>
<button
disabled={effectPage >= effectPageCount - 1}
onClick={() => setEffectPage((page) => Math.min(effectPageCount - 1, page + 1))}
type="button"
>
Next
</button>
</div>
)}
</section>
</div>
)}
<footer className="talent-footer"> <footer className="talent-footer">
<span>{message || 'Talent changes are saved immediately.'}</span> <span>{message || 'Spell effect changes are saved immediately.'}</span>
<button <button
className="text-button" className="text-button"
disabled={classPointsSpent === 0 || resetting} disabled={selectedEffects.length === 0 || resetting}
onClick={refundTree} onClick={clearEffects}
type="button" type="button"
> >
{resetting ? 'Refunding...' : 'Reset Tree'} {resetting ? 'Clearing...' : 'Clear Effects'}
</button> </button>
</footer> </footer>
</> </>
+113 -3
View File
@@ -42,7 +42,7 @@ export type DualScreenCombatState = {
partySize: number partySize: number
selectedId: string selectedId: string
log: CombatLogEntry[] log: CombatLogEntry[]
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice' status: 'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'
resource: number resource: number
maxResource: number maxResource: number
resourceName: string resourceName: string
@@ -54,14 +54,31 @@ export type DualScreenCombatState = {
directPartyTargeting: boolean directPartyTargeting: boolean
paused: boolean paused: boolean
targetGroup: 0 | 1 | 2 targetGroup: 0 | 1 | 2
speedMultiplier: 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
@@ -102,6 +119,13 @@ function loadRecentSnapshot() {
} }
} }
function memberHotEffects(member: PartyMember) {
if (member.hotEffects?.length) return member.hotEffects
return member.hotTicks > 0
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
: []
}
export function DualScreenProvider({ children }: { children: ReactNode }) { export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState( const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true', () => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -280,16 +304,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 +379,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">
@@ -340,6 +446,7 @@ export function DualScreenBottomDisplay() {
</div> </div>
<div className="dual-controls-mana"> <div className="dual-controls-mana">
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span> <span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"> <div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} /> <span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
</div> </div>
@@ -475,6 +582,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"
@@ -500,7 +608,9 @@ export function DualScreenTopCombat({
</div> </div>
)} )}
<div className="member-effects"> <div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew</span>} {memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label}</span>
))}
{member.debuff && <span className="debuff">{member.debuff}</span>} {member.debuff && <span className="debuff">{member.debuff}</span>}
</div> </div>
</button> </button>
+45 -2
View File
@@ -8,6 +8,20 @@ export type PartyMember = {
maxHealth: number maxHealth: number
shield: number shield: number
hotTicks: number hotTicks: number
hotEffects?: Array<{
id: string
spellId: string
label: string
ticks: number
power: number
}>
bounceHeals?: Array<{
id: string
label: string
charges: number
power: number
}>
damageReductionTicks?: number
debuff?: string debuff?: string
debuffTicks?: number debuffTicks?: number
poisonStacks?: number poisonStacks?: number
@@ -24,7 +38,8 @@ export type Spell = {
cooldown: number cooldown: number
power: number power: number
glyph: string glyph: string
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' | 'damage_reduction' | 'bounce_heal'
effectType?: string
} }
export type Encounter = { export type Encounter = {
@@ -44,6 +59,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 +119,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 +182,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)
}
+332 -72
View File
@@ -26,6 +26,7 @@ export interface GameRepository {
completedPart?: number, completedPart?: number,
startPart?: number, startPart?: number,
partDurationSeconds?: [number, number, number], partDurationSeconds?: [number, number, number],
hardMode?: boolean,
): Promise<DungeonReward> ): Promise<DungeonReward>
completeRoguelike( completeRoguelike(
dungeonId: number, dungeonId: number,
@@ -36,6 +37,8 @@ export interface GameRepository {
options?: { options?: {
bossesCleared?: number bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level' experienceMode?: 'default' | 'pvp-boss-quarter-level'
lootSourceEncounterId?: number
roguelikeStage?: number
}, },
): Promise<DungeonReward> ): Promise<DungeonReward>
allocateTalent(talentId: number): Promise<CharacterProfile> allocateTalent(talentId: number): Promise<CharacterProfile>
@@ -44,6 +47,7 @@ export interface GameRepository {
discardExtraItem(itemId: number): Promise<CharacterProfile> discardExtraItem(itemId: number): Promise<CharacterProfile>
breakdownItem(itemId: number): Promise<CharacterProfile> breakdownItem(itemId: number): Promise<CharacterProfile>
craftItem(recipeId: number): Promise<CharacterProfile> craftItem(recipeId: number): Promise<CharacterProfile>
upgradeItem(itemId: number): Promise<CharacterProfile>
rollEncounterLoot( rollEncounterLoot(
encounterId: number, encounterId: number,
difficultyId: number, difficultyId: number,
@@ -66,6 +70,7 @@ type OfflineSave = {
activeClassId: number activeClassId: number
completedDungeonParts: number completedDungeonParts: number
completedRaidPhases: number completedRaidPhases: number
dungeonCompletions?: Record<string, number>
characters: Record<number, CharacterData> characters: Record<number, CharacterData>
lootRolls: Record<string, LootRoll> lootRolls: Record<string, LootRoll>
} }
@@ -97,7 +102,9 @@ type LocalSaveStore = {
const modeKey = 'chronicle.repositoryMode' const modeKey = 'chronicle.repositoryMode'
const offlineSaveKey = 'chronicle.offlineSave.v1' const offlineSaveKey = 'chronicle.offlineSave.v1'
const onlineCacheKey = 'chronicle.onlineCache.v1' const onlineCacheKey = 'chronicle.onlineCache.v1'
const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' } const offlineAccount = { id: -1, username: 'Offline' }
const ABILITY_SLOT_COUNT = 6
function clone<T>(value: T): T { function clone<T>(value: T): T {
return structuredClone(value) return structuredClone(value)
@@ -142,7 +149,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
level: cid === p.character.classId ? p.character.level : 1, level: cid === p.character.classId ? p.character.level : 1,
experience: cid === p.character.classId ? p.character.experience : 0, experience: cid === p.character.classId ? p.character.experience : 0,
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1, talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
abilitySlots: cid === p.character.classId ? [...p.abilitySlots] : [], abilitySlots: cid === p.character.classId ? normalizeAbilitySlots(p.abilitySlots) : [],
talentRanks, talentRanks,
inventory: cid === p.character.classId ? clone(p.inventory) : [], inventory: cid === p.character.classId ? clone(p.inventory) : [],
} }
@@ -153,17 +160,41 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
activeClassId: p.character.classId, activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts, completedDungeonParts: p.completedDungeonParts,
completedRaidPhases: p.completedRaidPhases ?? 0, completedRaidPhases: p.completedRaidPhases ?? 0,
dungeonCompletions: Object.fromEntries(
p.dungeons.map((dungeon) => [String(dungeon.id), dungeon.completionCount ?? 0]),
),
characters, characters,
lootRolls: v1.lootRolls ?? {}, lootRolls: v1.lootRolls ?? {},
} }
} }
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave { function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
return { return normalizeSaveAbilitySlots({
...v2, ...v2,
version: 3, version: 3,
completedRaidPhases: 0, completedRaidPhases: 0,
})
}
function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
const slots = Array.isArray(abilitySlots)
? abilitySlots
.slice(0, ABILITY_SLOT_COUNT)
.map((value) => {
if (value === null || value === undefined) return null
const id = Number(value)
return Number.isInteger(id) ? id : null
})
: []
while (slots.length < ABILITY_SLOT_COUNT) slots.push(null)
return slots
}
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
for (const character of Object.values(save.characters)) {
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
} }
return save
} }
function normalizeOfflineSave(raw: unknown): OfflineSave | null { function normalizeOfflineSave(raw: unknown): OfflineSave | null {
@@ -173,12 +204,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
profile?: CharacterProfile profile?: CharacterProfile
lootRolls?: Record<string, LootRoll> lootRolls?: Record<string, LootRoll>
} }
if (candidate.version === 3) return candidate as OfflineSave if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave)
if (candidate.version === 2) { if (candidate.version === 2) {
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }) return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
} }
if (candidate.version === 1 && candidate.profile) { if (candidate.version === 1 && candidate.profile) {
return upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }) return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
} }
return null return null
} }
@@ -287,6 +318,10 @@ function buildProfile(save: OfflineSave): CharacterProfile {
updateCraftingRecipes(static_) updateCraftingRecipes(static_)
static_.completedDungeonParts = save.completedDungeonParts static_.completedDungeonParts = save.completedDungeonParts
static_.completedRaidPhases = save.completedRaidPhases static_.completedRaidPhases = save.completedRaidPhases
static_.dungeons = static_.dungeons.map((dungeon) => ({
...dungeon,
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
}))
return static_ return static_
} }
@@ -355,11 +390,33 @@ function experienceForLevel(level: number) {
return (level - 1) * (level - 1) * 100 return (level - 1) * (level - 1) * 100
} }
function catchUpExperienceReward(
baseReward: number,
currentExperience: number,
currentLevel: number,
targetLevel: number,
) {
if (targetLevel <= currentLevel) return baseReward
const targetExperience = experienceForLevel(targetLevel)
const gap = Math.max(0, targetExperience - currentExperience)
if (gap <= 0) return baseReward
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
return doubledBase * 2 + (baseReward - doubledBase)
}
function highestOtherClassLevel(save: OfflineSave) {
const activeClass = save.activeClassId
return Object.entries(save.characters)
.filter(([classId]) => Number(classId) !== activeClass)
.reduce((highest, [, character]) => Math.max(highest, character.level), 0)
}
function scaledPvpBossExperience( function scaledPvpBossExperience(
startingExperience: number, startingExperience: number,
startingLevel: number, startingLevel: number,
bossesCleared: number, bossesCleared: number,
maxLevel: number, maxLevel: number,
targetLevel = startingLevel,
) { ) {
let experience = startingExperience let experience = startingExperience
let level = startingLevel let level = startingLevel
@@ -370,7 +427,8 @@ function scaledPvpBossExperience(
? maxExperience ? maxExperience
: experienceForLevel(level + 1) : experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25)) const rewardRate = targetLevel > level ? 0.5 : 0.25
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) { while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1 level += 1
} }
@@ -378,18 +436,29 @@ function scaledPvpBossExperience(
return { experience, level } return { experience, level }
} }
function talentEffectCapacity(level: number) {
return Math.min(4, Math.max(0, Math.floor(level / 5)))
}
function talentEffectSource(effectType: string) {
if (effectType.startsWith('mend_')) return 'Mend'
if (effectType.startsWith('radiance_')) return 'Radiance'
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
return effectType
}
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.' },
} }
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
type WindowWithApiBase = Window & { type WindowWithApiBase = Window & {
CAPACITOR_API_BASE_URL?: string CAPACITOR_API_BASE_URL?: string
CAPACITOR_AUTH_API_BASE_URL?: string
} }
function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined { function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined {
@@ -401,13 +470,6 @@ function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined
return COMPONENT_ITEMS[best] return COMPONENT_ITEMS[best]
} }
function componentDropQuantity(itemLevel: number) {
const tier = Math.max(0, Math.floor((itemLevel - 5) / 5))
const secondChance = Math.min(0.85, 0.35 + tier * 0.12)
const thirdChance = Math.min(0.6, 0.1 + tier * 0.1)
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0)
}
function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineSave): OfflineSave { function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineSave): OfflineSave {
const characters = clone(existingSave?.characters ?? {}) const characters = clone(existingSave?.characters ?? {})
for (const gameClass of profile.classes) { for (const gameClass of profile.classes) {
@@ -427,7 +489,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
level: profile.character.level, level: profile.character.level,
experience: profile.character.experience, experience: profile.character.experience,
talentPoints: profile.character.talentPoints, talentPoints: profile.character.talentPoints,
abilitySlots: [...profile.abilitySlots], abilitySlots: normalizeAbilitySlots(profile.abilitySlots),
talentRanks, talentRanks,
inventory: clone(profile.inventory), inventory: clone(profile.inventory),
} }
@@ -437,6 +499,9 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
activeClassId: profile.character.classId, activeClassId: profile.character.classId,
completedDungeonParts: profile.completedDungeonParts, completedDungeonParts: profile.completedDungeonParts,
completedRaidPhases: profile.completedRaidPhases, completedRaidPhases: profile.completedRaidPhases,
dungeonCompletions: Object.fromEntries(
profile.dungeons.map((dungeon) => [String(dungeon.id), dungeon.completionCount ?? 0]),
),
characters, characters,
lootRolls: clone(existingSave?.lootRolls ?? {}), lootRolls: clone(existingSave?.lootRolls ?? {}),
} }
@@ -452,25 +517,107 @@ function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]):
return entries[entries.length - 1] return entries[entries.length - 1]
} }
function getApiBaseUrl(): string { function coinDropQuantity() {
const roll = Math.random()
if (roll < 0.15) return 3
if (roll < 0.5) return 2
return 1
}
function roguelikeCoinItemLevel(stage: number) {
return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5)
}
function awardRoguelikeCoin(
profile: CharacterProfile,
sourceEncounterId: number | undefined,
stage: number | undefined,
): DungeonReward['bonusItem'] {
if (!sourceEncounterId || !stage) return null
const targetItemLevel = roguelikeCoinItemLevel(stage)
const sourceEncounter = profile.dungeons
.flatMap((dungeon) => dungeon.encounters)
.find((encounter) => encounter.id === sourceEncounterId)
const coin = sourceEncounter?.lootTables
.filter((entry) => entry.itemLevel === targetItemLevel)
.sort((left, right) => left.difficultyId - right.difficultyId)[0]
if (!coin) return null
const {
encounterId: _encounterId,
difficultyId: _difficultyId,
dropWeight: _dropWeight,
dropChance: _dropChance,
...coinItem
} = coin
void _encounterId
void _difficultyId
void _dropWeight
void _dropChance
const quantity = coinDropQuantity()
const added = addInventoryItem(profile.inventory, {
...coinItem,
slot: coinItem.slot as EquipmentSlot,
rarity: coinItem.rarity as Item['rarity'],
}, quantity)
return {
...coinItem,
quantity,
duplicate: added.duplicate,
quantityAfter: added.quantityAfter,
}
}
function readAuthToken(): string {
return localStorage.getItem(authTokenKey) ?? ''
}
function writeAuthToken(token: string) {
localStorage.setItem(authTokenKey, token)
}
function clearAuthToken() {
localStorage.removeItem(authTokenKey)
}
function configuredBaseUrl(value: string | undefined): string {
return value ? value.replace(/\/+$/, '') : ''
}
function getApiBaseUrl(path: string): string {
const browserWindow = typeof window === 'undefined' const browserWindow = typeof window === 'undefined'
? undefined ? undefined
: window as WindowWithApiBase : window as WindowWithApiBase
if (path.startsWith('/api/auth/')) {
if (browserWindow?.CAPACITOR_AUTH_API_BASE_URL) {
return configuredBaseUrl(browserWindow.CAPACITOR_AUTH_API_BASE_URL)
}
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_AUTH_API_BASE_URL) {
return configuredBaseUrl(import.meta.env.VITE_AUTH_API_BASE_URL)
}
}
if (browserWindow?.CAPACITOR_API_BASE_URL) { if (browserWindow?.CAPACITOR_API_BASE_URL) {
return browserWindow.CAPACITOR_API_BASE_URL return configuredBaseUrl(browserWindow.CAPACITOR_API_BASE_URL)
} }
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) { if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) {
return import.meta.env.VITE_API_BASE_URL return configuredBaseUrl(import.meta.env.VITE_API_BASE_URL)
} }
return '' return ''
} }
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> { async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = getApiBaseUrl() const baseUrl = getApiBaseUrl(path)
const url = baseUrl ? `${baseUrl}${path}` : path const url = baseUrl ? `${baseUrl}${path}` : path
const headers = new Headers(init?.headers)
const token = readAuthToken()
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
let response: Response let response: Response
try { try {
response = await fetch(url, init) response = await fetch(url, {
...init,
headers,
})
} catch (reason) { } catch (reason) {
const networkError = new Error('Unable to reach the game server.') as NetworkError const networkError = new Error('Unable to reach the game server.') as NetworkError
networkError.network = true networkError.network = true
@@ -525,8 +672,18 @@ async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: Charact
} }
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> { async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
if (!session.account || !session.profile) return session
const cache = readOnlineCache() const cache = readOnlineCache()
if (session.token) writeAuthToken(session.token)
if (!session.account || !session.profile) {
if (session.account && cache?.account.id === session.account.id) {
return {
account: session.account,
profile: buildProfile(cache.save),
token: session.token,
}
}
return session
}
if (cache?.account.id === session.account.id && cache.dirty) { if (cache?.account.id === session.account.id && cache.dirty) {
writeOnlineCache({ writeOnlineCache({
...cache, ...cache,
@@ -535,6 +692,7 @@ async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession>
return { return {
account: session.account, account: session.account,
profile: buildProfile(cache.save), profile: buildProfile(cache.save),
token: session.token,
} }
} }
try { try {
@@ -611,6 +769,7 @@ const serverRepository: GameRepository = {
} catch (reason) { } catch (reason) {
if (!isNetworkError(reason)) throw reason if (!isNetworkError(reason)) throw reason
} }
clearAuthToken()
clearOnlineCache() clearOnlineCache()
writeMode('online') writeMode('online')
}, },
@@ -626,7 +785,7 @@ const serverRepository: GameRepository = {
), ),
saveProfile: (classId, abilitySlots) => saveProfile: (classId, abilitySlots) =>
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots), cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) => completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
cachedOnlineLocalRepository.completeDungeon( cachedOnlineLocalRepository.completeDungeon(
dungeonId, dungeonId,
difficultyId, difficultyId,
@@ -635,6 +794,7 @@ const serverRepository: GameRepository = {
completedPart, completedPart,
startPart, startPart,
partDurationSeconds, partDurationSeconds,
hardMode,
), ),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) => completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike( cachedOnlineLocalRepository.completeRoguelike(
@@ -657,6 +817,8 @@ const serverRepository: GameRepository = {
cachedOnlineLocalRepository.breakdownItem(itemId), cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) => craftItem: (recipeId) =>
cachedOnlineLocalRepository.craftItem(recipeId), cachedOnlineLocalRepository.craftItem(recipeId),
upgradeItem: (itemId) =>
cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) => rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken), cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
} }
@@ -666,13 +828,12 @@ 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, ABILITY_SLOT_COUNT)
.map((s) => s.id) .map((s) => s.id)
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null) while (startingAbilitySlots.length < ABILITY_SLOT_COUNT) startingAbilitySlots.push(null)
return { return {
level: 1, level: 1,
experience: 0, experience: 0,
@@ -712,35 +873,34 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}, },
async saveProfile(classId, abilitySlots) { async saveProfile(classId, abilitySlots) {
const save = requireStoredSave(store) const save = requireStoredSave(store)
const static_ = clone(starterProfile) as CharacterProfile const static_ = clone(starterProfile) as CharacterProfile
const gameClass = static_.classes.find((candidate) => candidate.id === classId) const gameClass = static_.classes.find((candidate) => candidate.id === classId)
if (!gameClass) throw new Error('Selected class does not exist.') if (!gameClass) throw new Error('Selected class does not exist.')
const slots = abilitySlots.slice(0, 6) const slots = normalizeAbilitySlots(abilitySlots)
while (slots.length < 6) slots.push(null) const selectedIds = slots.filter((id): id is number => id !== null)
const selectedIds = slots.filter((id): id is number => id !== null) if (new Set(selectedIds).size !== selectedIds.length) {
if (new Set(selectedIds).size !== selectedIds.length) { throw new Error('The same ability cannot be equipped twice.')
throw new Error('The same ability cannot be equipped twice.') }
} const activeChar = save.characters[save.activeClassId]
const activeChar = save.characters[save.activeClassId] const validIds = new Set(
const validIds = new Set( gameClass.spells
gameClass.spells .filter((spell) => spell.unlockLevel <= activeChar.level)
.filter((spell) => spell.unlockLevel <= activeChar.level) .map((spell) => spell.id),
.map((spell) => spell.id), )
) if (selectedIds.some((id) => !validIds.has(id))) {
if (selectedIds.some((id) => !validIds.has(id))) { throw new Error('One or more abilities are locked or belong to another class.')
throw new Error('One or more abilities are locked or belong to another class.') }
}
if (!save.characters[classId]) { if (!save.characters[classId]) {
save.characters[classId] = emptyCharacterData(classId) save.characters[classId] = emptyCharacterData(classId)
} }
save.characters[classId].abilitySlots = slots save.characters[classId].abilitySlots = slots
save.activeClassId = classId save.activeClassId = classId
store.writeSave(save) store.writeSave(save)
return buildProfile(save) return buildProfile(save)
}, },
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) { async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
void startPart void startPart
void partDurationSeconds void partDurationSeconds
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) { if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
@@ -766,8 +926,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const previousLevel = cd.level const previousLevel = cd.level
const previousExperience = cd.experience const previousExperience = cd.experience
const partCount = completedPart ?? 1 const partCount = completedPart ?? 1
const experienceReward = Math.round( const rewardMultiplier = hardMode ? 2 : 1
dungeon.experienceReward * difficulty.experienceMultiplier * partCount, const baseExperienceReward = Math.round(
dungeon.experienceReward * difficulty.experienceMultiplier * partCount * rewardMultiplier,
)
const experienceReward = catchUpExperienceReward(
baseExperienceReward,
previousExperience,
previousLevel,
highestOtherClassLevel(save),
) )
const maxExperience = experienceForLevel(profile.maxLevel) const maxExperience = experienceForLevel(profile.maxLevel)
const newExperience = Math.min(previousExperience + experienceReward, maxExperience) const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
@@ -802,6 +969,10 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
} else { } else {
save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount) save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount)
} }
save.dungeonCompletions = {
...(save.dungeonCompletions ?? {}),
[String(dungeonId)]: (save.dungeonCompletions?.[String(dungeonId)] ?? 0) + 1,
}
let bonusItem: DungeonReward['bonusItem'] = null let bonusItem: DungeonReward['bonusItem'] = null
if ((startPart ?? 1) === 1 && partCount >= 3 && dungeon.completionLoot.length > 0) { if ((startPart ?? 1) === 1 && partCount >= 3 && dungeon.completionLoot.length > 0) {
@@ -815,19 +986,20 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)] const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
const existing = profile.inventory.find((item) => item.id === selected.id) const existing = profile.inventory.find((item) => item.id === selected.id)
const duplicate = Boolean(existing) const duplicate = Boolean(existing)
let quantityAfter = 1 const rewardQuantity = rewardMultiplier
let quantityAfter = rewardQuantity
if (existing) { if (existing) {
existing.quantity += 1 existing.quantity += rewardQuantity
quantityAfter = existing.quantity quantityAfter = existing.quantity
} else { } else {
profile.inventory.push({ profile.inventory.push({
...selected, ...selected,
quantity: 1, quantity: rewardQuantity,
equipped: false, equipped: false,
}) })
} }
cd.inventory = profile.inventory cd.inventory = profile.inventory
bonusItem = { ...selected, duplicate, quantityAfter } bonusItem = { ...selected, quantity: rewardQuantity, duplicate, quantityAfter }
} }
} }
@@ -880,13 +1052,19 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const maxExperience = experienceForLevel(profile.maxLevel) const maxExperience = experienceForLevel(profile.maxLevel)
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3)) const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level' const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel) ? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
: null : null
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
const newExperience = scaledReward const newExperience = scaledReward
? scaledReward.experience ? scaledReward.experience
: Math.min( : Math.min(
previousExperience previousExperience
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)), + catchUpExperienceReward(
baseRoguelikeReward,
previousExperience,
previousLevel,
highestOtherClassLevel(save),
),
maxExperience, maxExperience,
) )
let newLevel = scaledReward?.level ?? previousLevel let newLevel = scaledReward?.level ?? previousLevel
@@ -914,6 +1092,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
profile.maxTalentPoints, profile.maxTalentPoints,
cd.talentPoints + levelsGained, cd.talentPoints + levelsGained,
) )
const bonusItem = awardRoguelikeCoin(
profile,
options?.lootSourceEncounterId,
options?.roguelikeStage,
)
cd.inventory = profile.inventory
store.writeSave(save) store.writeSave(save)
const updatedProfile = buildProfile(save) const updatedProfile = buildProfile(save)
@@ -931,7 +1115,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
durationSeconds, durationSeconds,
averageItemLevel: updatedProfile.gearStats.averageItemLevel, averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities, unlockedAbilities,
bonusItem: null, bonusItem,
profile: updatedProfile, profile: updatedProfile,
} }
}, },
@@ -944,6 +1128,34 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
)! )!
const talent = gameClass.talents.find((candidate) => candidate.id === talentId) const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
if (!talent) throw new Error('That talent does not belong to the active class.') if (!talent) throw new Error('That talent does not belong to the active class.')
if (save.activeClassId === 1) {
if ((cd.talentRanks[String(talentId)] ?? 0) > 0) {
cd.talentRanks[String(talentId)] = 0
} else {
const capacity = talentEffectCapacity(cd.level)
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
const source = talentEffectSource(talent.effectType)
const sourceConflict = gameClass.talents.find(
(candidate) =>
candidate.id !== talentId
&& (cd.talentRanks[String(candidate.id)] ?? 0) > 0
&& talentEffectSource(candidate.effectType) === source,
)
if (sourceConflict) {
throw new Error(`Only one ${source} spell effect can be active.`)
}
const activeCount = gameClass.talents.reduce(
(total, candidate) => total + ((cd.talentRanks[String(candidate.id)] ?? 0) > 0 ? 1 : 0),
0,
)
if (activeCount >= capacity) {
throw new Error(`Level ${cd.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
}
cd.talentRanks[String(talentId)] = 1
}
store.writeSave(save)
return buildProfile(save)
}
if (cd.talentPoints <= 0) { if (cd.talentPoints <= 0) {
throw new Error('No talent points are available.') throw new Error('No talent points are available.')
} }
@@ -986,10 +1198,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
for (const talent of gameClass.talents) { for (const talent of gameClass.talents) {
cd.talentRanks[String(talent.id)] = 0 cd.talentRanks[String(talent.id)] = 0
} }
cd.talentPoints = Math.min( if (save.activeClassId !== 1) {
profile.maxTalentPoints, cd.talentPoints = Math.min(
cd.talentPoints + refunded, profile.maxTalentPoints,
) cd.talentPoints + refunded,
)
}
store.writeSave(save) store.writeSave(save)
return buildProfile(save) return buildProfile(save)
}, },
@@ -1064,6 +1278,8 @@ 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 = !DIRECT_CRAFT_ITEM_LEVELS.has(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.`)
@@ -1083,6 +1299,53 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
store.writeSave(save) store.writeSave(save)
return buildProfile(save) return buildProfile(save)
}, },
async upgradeItem(itemId) {
const save = requireStoredSave(store)
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
if (item.slot === 'component') throw new Error('Components cannot be upgraded.')
const currentRecipe = profile.craftingRecipes.find((recipe) => recipe.item.id === item.id)
const targetRecipe = currentRecipe
? profile.craftingRecipes
.filter((recipe) =>
recipe.sourceEncounterId === currentRecipe.sourceEncounterId
&& recipe.item.slot === item.slot
&& recipe.item.itemLevel > item.itemLevel,
)
.sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0]
: null
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
const missing = targetRecipe.components.find((component) => component.owned < component.quantity)
if (missing) {
throw new Error(`Need ${missing.quantity} ${missing.item.name} to upgrade this item.`)
}
for (const component of targetRecipe.components) {
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to upgrade this item.`)
owned.quantity -= component.quantity
}
const wasEquipped = item.equipped
item.quantity -= 1
item.equipped = false
for (let index = profile.inventory.length - 1; index >= 0; index -= 1) {
if (profile.inventory[index].quantity <= 0) profile.inventory.splice(index, 1)
}
if (wasEquipped) {
for (const candidate of profile.inventory) {
if (candidate.slot === targetRecipe.item.slot) candidate.equipped = false
}
}
addInventoryItem(profile.inventory, targetRecipe.item, 1)
if (wasEquipped) {
const upgraded = profile.inventory.find((candidate) => candidate.id === targetRecipe.item.id)
if (upgraded) upgraded.equipped = true
}
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
return buildProfile(save)
},
async rollEncounterLoot(encounterId, difficultyId, runToken) { async rollEncounterLoot(encounterId, difficultyId, runToken) {
if (runToken.length < 8 || runToken.length > 100) { if (runToken.length < 8 || runToken.length > 100) {
throw new Error('A valid dungeon run token is required.') throw new Error('A valid dungeon run token is required.')
@@ -1108,17 +1371,11 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const items: LootRoll['items'] = [] const items: LootRoll['items'] = []
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>() const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
const dungeon = profile.dungeons.find((candidate) => if (Math.random() < dropChance) {
candidate.encounters.some((dungeonEncounter) => dungeonEncounter.id === encounterId),
)
const lootChanceSlots = dungeon?.contentType === 'raid' ? 8 : 5
for (let index = 0; index < lootChanceSlots; index += 1) {
if (Math.random() >= dropChance) continue
const selected = rollWeightedLootEntry(entries) const selected = rollWeightedLootEntry(entries)
const current = selectedQuantities.get(selected.id)
selectedQuantities.set(selected.id, { selectedQuantities.set(selected.id, {
entry: selected, entry: selected,
quantity: (current?.quantity ?? 0) + componentDropQuantity(difficulty.droppedItemLevel), quantity: coinDropQuantity(),
}) })
} }
@@ -1211,12 +1468,13 @@ const cachedOnlineRepository: GameRepository = {
throw new Error('Account login requires online mode.') throw new Error('Account login requires online mode.')
}, },
async logout() { async logout() {
clearAuthToken()
clearOnlineCache() clearOnlineCache()
writeMode('online') writeMode('online')
}, },
loadProfile: () => cachedOnlineLocalRepository.loadProfile(), loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots), saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) => completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
cachedOnlineLocalRepository.completeDungeon( cachedOnlineLocalRepository.completeDungeon(
dungeonId, dungeonId,
difficultyId, difficultyId,
@@ -1225,6 +1483,7 @@ const cachedOnlineRepository: GameRepository = {
completedPart, completedPart,
startPart, startPart,
partDurationSeconds, partDurationSeconds,
hardMode,
), ),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) => completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike( cachedOnlineLocalRepository.completeRoguelike(
@@ -1241,6 +1500,7 @@ const cachedOnlineRepository: GameRepository = {
discardExtraItem: (itemId) => cachedOnlineLocalRepository.discardExtraItem(itemId), discardExtraItem: (itemId) => cachedOnlineLocalRepository.discardExtraItem(itemId),
breakdownItem: (itemId) => cachedOnlineLocalRepository.breakdownItem(itemId), breakdownItem: (itemId) => cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) => cachedOnlineLocalRepository.craftItem(recipeId), craftItem: (recipeId) => cachedOnlineLocalRepository.craftItem(recipeId),
upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) => rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken), cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
} }
+18 -16
View File
@@ -35,6 +35,7 @@ export const INPUT_ACTIONS = [
'targetParty5', 'targetParty5',
'targetParty6', 'targetParty6',
'toggleTargetGroup', 'toggleTargetGroup',
'toggleSpeed',
'pause', 'pause',
] as const ] as const
@@ -63,6 +64,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
targetParty5: 'Target Party Member 5', targetParty5: 'Target Party Member 5',
targetParty6: 'Target Party Member 6', targetParty6: 'Target Party Member 6',
toggleTargetGroup: 'Switch Raid Target Group', toggleTargetGroup: 'Switch Raid Target Group',
toggleSpeed: 'Toggle 2x Speed',
pause: 'Pause Menu', pause: 'Pause Menu',
} }
@@ -89,6 +91,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty5: 'F5', targetParty5: 'F5',
targetParty6: 'F6', targetParty6: 'F6',
toggleTargetGroup: 'Tab', toggleTargetGroup: 'Tab',
toggleSpeed: 'Backquote',
pause: 'Escape', pause: 'Escape',
}, },
controller: { controller: {
@@ -111,8 +114,9 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty3: 'Button15', targetParty3: 'Button15',
targetParty4: 'Button13', targetParty4: 'Button13',
targetParty5: 'Button4', targetParty5: 'Button4',
targetParty6: 'Button11', targetParty6: 'Button10',
toggleTargetGroup: 'Button6', toggleTargetGroup: 'Button6',
toggleSpeed: 'Button11',
pause: 'Button9', pause: 'Button9',
}, },
} }
@@ -145,7 +149,8 @@ const InputContext = createContext<InputContextValue | null>(null)
function loadBindings(): Record<InputDevice, InputBindings> { function loadBindings(): Record<InputDevice, InputBindings> {
try { try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>> const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
const controller = { ...DEFAULT_BINDINGS.controller, ...saved.controller } const savedController = saved.controller
const controller = { ...DEFAULT_BINDINGS.controller, ...savedController }
const usesLegacyAbilityDefaults = [ const usesLegacyAbilityDefaults = [
'Button2', 'Button2',
'Button3', 'Button3',
@@ -166,6 +171,15 @@ function loadBindings(): Record<InputDevice, InputBindings> {
ability6: DEFAULT_BINDINGS.controller.ability6, ability6: DEFAULT_BINDINGS.controller.ability6,
}) })
} }
if (savedController?.toggleSpeed === 'Button7') {
controller.toggleSpeed = DEFAULT_BINDINGS.controller.toggleSpeed
}
if (savedController?.ability6 === 'Button10') {
controller.ability6 = DEFAULT_BINDINGS.controller.ability6
}
if (savedController?.targetParty6 === 'Button11') {
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
}
return { return {
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc }, pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
controller, controller,
@@ -276,14 +290,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 +403,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 +449,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
@@ -518,9 +518,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
'targetParty5', 'targetParty5',
'targetParty6', 'targetParty6',
'toggleTargetGroup', 'toggleTargetGroup',
'toggleSpeed',
] satisfies InputAction[] ] satisfies InputAction[]
const combatPriority = [ const combatPriority = [
'pause', 'pause',
'toggleSpeed',
'ability1', 'ability1',
'ability2', 'ability2',
'ability3', 'ability3',
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -59,7 +59,7 @@ export type Item = {
slug: string slug: string
name: string name: string
slot: EquipmentSlot slot: EquipmentSlot
rarity: 'common' | 'uncommon' | 'rare' | 'epic' rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
itemLevel: number itemLevel: number
healingPower: number healingPower: number
maxResourceBonus: number maxResourceBonus: number
@@ -168,6 +168,7 @@ export type Dungeon = {
difficulties: Difficulty[] difficulties: Difficulty[]
encounters: DungeonEncounter[] encounters: DungeonEncounter[]
completionLoot: Array<Omit<Item, 'quantity' | 'equipped'>> completionLoot: Array<Omit<Item, 'quantity' | 'equipped'>>
completionCount?: number
leaderboard: LeaderboardEntry[] leaderboard: LeaderboardEntry[]
leaderboards: { leaderboards: {
part_1: LeaderboardEntry[] part_1: LeaderboardEntry[]
@@ -234,6 +235,7 @@ export type Account = {
export type AuthSession = { export type AuthSession = {
account: Account | null account: Account | null
profile: CharacterProfile | null profile: CharacterProfile | null
token?: string
} }
export type BonusItem = { export type BonusItem = {
@@ -247,6 +249,7 @@ export type BonusItem = {
maxResourceBonus: number maxResourceBonus: number
glyph: string glyph: string
description: string description: string
quantity: number
duplicate: boolean duplicate: boolean
quantityAfter: number quantityAfter: number
} }
@@ -317,6 +320,7 @@ export async function completeDungeon(
completedPart?: number, completedPart?: number,
startPart?: number, startPart?: number,
partDurationSeconds?: [number, number, number], partDurationSeconds?: [number, number, number],
hardMode?: boolean,
): Promise<DungeonReward> { ): Promise<DungeonReward> {
return activeGameRepository().completeDungeon( return activeGameRepository().completeDungeon(
dungeonId, dungeonId,
@@ -326,6 +330,7 @@ export async function completeDungeon(
completedPart, completedPart,
startPart, startPart,
partDurationSeconds, partDurationSeconds,
hardMode,
) )
} }
@@ -338,6 +343,8 @@ export async function completeRoguelike(
options?: { options?: {
bossesCleared?: number bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level' experienceMode?: 'default' | 'pvp-boss-quarter-level'
lootSourceEncounterId?: number
roguelikeStage?: number
}, },
): Promise<DungeonReward> { ): Promise<DungeonReward> {
return activeGameRepository().completeRoguelike( return activeGameRepository().completeRoguelike(
@@ -374,6 +381,10 @@ export async function craftItem(recipeId: number): Promise<CharacterProfile> {
return activeGameRepository().craftItem(recipeId) return activeGameRepository().craftItem(recipeId)
} }
export async function upgradeItem(itemId: number): Promise<CharacterProfile> {
return activeGameRepository().upgradeItem(itemId)
}
export async function rollEncounterLoot( export async function rollEncounterLoot(
encounterId: number, encounterId: number,
difficultyId: number, difficultyId: number,
+22
View File
@@ -12,6 +12,7 @@ export type CpuPvpLeaderboardEntry = {
} }
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1' const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
const checkpointKey = 'chronicle.pvpRoguelikeCheckpoint.v1'
export function randomCpuDifficulty(): CpuDifficulty { export function randomCpuDifficulty(): CpuDifficulty {
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
@@ -44,3 +45,24 @@ export function recordCpuPvpLeaderboard(entry: CpuPvpLeaderboardEntry) {
.slice(0, 30) .slice(0, 30)
localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next)) localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next))
} }
function checkpointStorageKey(characterId: number, contentType: PvpContentType) {
return `${checkpointKey}:${characterId}:${contentType}`
}
export function loadPvpRoguelikeCheckpoint(characterId: number, contentType: PvpContentType) {
const value = Number(localStorage.getItem(checkpointStorageKey(characterId, contentType)) ?? 1)
return Number.isInteger(value) && value >= 5 ? value : 1
}
export function recordPvpRoguelikeCheckpoint(
characterId: number,
contentType: PvpContentType,
stage: number,
) {
if (stage < 5 || stage % 5 !== 0) return loadPvpRoguelikeCheckpoint(characterId, contentType)
const current = loadPvpRoguelikeCheckpoint(characterId, contentType)
const next = Math.max(current, stage)
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
return next
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"files": ["vite.config.ts"]
}