Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7b041f86f | |||
| 05bd70a9fe | |||
| bb5c7e6e21 | |||
| 14bec979e6 | |||
| 4b45483ac3 | |||
| 6e10b37f8e | |||
| 5aac39c6c9 | |||
| 224249e372 | |||
| 66f5af4484 | |||
| 8f5a957963 | |||
| f8a1fbc5e2 | |||
| bab2dce6c3 | |||
| cb38042eca | |||
| 753bba581a | |||
| 2300973164 | |||
| 1281be69d8 | |||
| 4fc15ebe9a | |||
| 7313c968e6 | |||
| 207fcd1a15 | |||
| fd6a1ce3c7 | |||
| f8b98e6b23 | |||
| fc7c6488ea | |||
| ba6d3b614e | |||
| 88874933c3 | |||
| bf12aefeeb | |||
| 814eb1998d | |||
| 7fe62d8c82 | |||
| 3a8d5ad8c5 |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "local-plugins",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Local Plugins"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "caveman",
|
||||||
|
"source": {
|
||||||
|
"source": "local",
|
||||||
|
"path": "./.codex-plugins/caveman"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"installation": "AVAILABLE",
|
||||||
|
"authentication": "ON_INSTALL"
|
||||||
|
},
|
||||||
|
"category": "Productivity"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "caveman",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Ultra-compressed communication mode. Cut filler. Keep technical accuracy.",
|
||||||
|
"author": {
|
||||||
|
"name": "Julius Brussee",
|
||||||
|
"url": "https://github.com/JuliusBrussee"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/JuliusBrussee/caveman",
|
||||||
|
"repository": "https://github.com/JuliusBrussee/caveman",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"productivity",
|
||||||
|
"communication",
|
||||||
|
"brevity",
|
||||||
|
"writing"
|
||||||
|
],
|
||||||
|
"skills": "./skills/",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Caveman",
|
||||||
|
"shortDescription": "Talk like caveman. Cut filler. Keep technical accuracy.",
|
||||||
|
"longDescription": "Ultra-compressed communication mode for Codex. Use fewer words. Keep exact technical substance.",
|
||||||
|
"developerName": "Julius Brussee",
|
||||||
|
"category": "Productivity",
|
||||||
|
"capabilities": [
|
||||||
|
"Write"
|
||||||
|
],
|
||||||
|
"websiteURL": "https://github.com/JuliusBrussee/caveman",
|
||||||
|
"privacyPolicyURL": "https://github.com/JuliusBrussee/caveman/blob/main/README.md",
|
||||||
|
"termsOfServiceURL": "https://github.com/JuliusBrussee/caveman/blob/main/LICENSE",
|
||||||
|
"defaultPrompt": [
|
||||||
|
"Use caveman mode. Cut filler. Keep technical accuracy."
|
||||||
|
],
|
||||||
|
"composerIcon": "./assets/caveman-small.svg",
|
||||||
|
"logo": "./assets/caveman.svg",
|
||||||
|
"screenshots": [],
|
||||||
|
"brandColor": "#6B7280"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: caveman
|
||||||
|
description: >
|
||||||
|
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||||
|
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
|
||||||
|
wenyan-lite, wenyan-full, wenyan-ultra.
|
||||||
|
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||||
|
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
|
||||||
|
---
|
||||||
|
|
||||||
|
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||||
|
|
||||||
|
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||||
|
|
||||||
|
Pattern: `[thing] [action] [reason]. [next step].`
|
||||||
|
|
||||||
|
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
||||||
|
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||||
|
|
||||||
|
## Intensity
|
||||||
|
|
||||||
|
| Level | What change |
|
||||||
|
|-------|------------|
|
||||||
|
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||||
|
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
|
||||||
|
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
|
||||||
|
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
||||||
|
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||||
|
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
|
||||||
|
|
||||||
|
Example — "Why React component re-render?"
|
||||||
|
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||||
|
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||||
|
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||||
|
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
|
||||||
|
- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。"
|
||||||
|
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
|
||||||
|
|
||||||
|
Example — "Explain database connection pooling."
|
||||||
|
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||||
|
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||||
|
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
|
||||||
|
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
|
||||||
|
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
|
||||||
|
|
||||||
|
## Auto-Clarity
|
||||||
|
|
||||||
|
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user confused. Resume caveman after clear part done.
|
||||||
|
|
||||||
|
Example — destructive op:
|
||||||
|
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||||
|
> ```sql
|
||||||
|
> DROP TABLE users;
|
||||||
|
> ```
|
||||||
|
> Caveman resume. Verify backup exist first.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ For an online production build, see [DEPLOYMENT.md](DEPLOYMENT.md).
|
|||||||
- Dungeon XP rewards, level-up handling, talent-point awards, and ability unlocks
|
- Dungeon XP rewards, level-up handling, talent-point awards, and ability unlocks
|
||||||
- SQLite-backed class talent trees with ranks, tiers, prerequisites, refunds,
|
- SQLite-backed class talent trees with ranks, tiers, prerequisites, refunds,
|
||||||
and immediate persistence
|
and immediate persistence
|
||||||
- Seven equipment slots, starter item-level 1 gear, inventory comparison, and
|
- Nine equipment slots, empty starter inventory, craftable item-level 1 gear,
|
||||||
persistent equipping
|
inventory comparison, and persistent equipping
|
||||||
- Aggregate item level, healing power, and resource bonuses that affect combat
|
- Aggregate item level, healing power, and resource bonuses that affect combat
|
||||||
- Five SQLite-authored dungeon difficulty tiers with level gates, combat
|
- Four playable SQLite-authored content tiers at item levels 1, 10, 20, and 25
|
||||||
scaling, XP multipliers, and item-level reward bands
|
with level gates, combat scaling, XP multipliers, and reward bands
|
||||||
- Encounter-specific weighted loot tables for every difficulty, with authored
|
- Gear progression through item levels 1, 5, 10, 15, 20, and 25 with
|
||||||
drop chances, slot pools, and item-level 5 through 25 reward variants
|
boss-coin crafting and upgrade steps
|
||||||
- One live loot roll per defeated encounter, shown in the combat log and
|
- One live loot roll per defeated encounter, shown in the combat log and
|
||||||
dungeon-complete summary
|
dungeon-complete summary
|
||||||
- Atomic inventory awards with retry-safe roll records and stacked duplicate
|
- Atomic inventory awards with retry-safe roll records and stacked duplicate
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 32
|
versionCode 68
|
||||||
versionName "1.0.21"
|
versionName "1.0.49"
|
||||||
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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,674 @@
|
|||||||
|
-- Generated by local admin panel. Commit this file with uploaded art changes.
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
UPDATE dungeons SET slug = 'bulldrome-hunting-ground', name = 'Bulldrome Hunting Ground', recommended_level = 1, content_type = 'dungeon', party_size = 6, experience_reward = 125, image_url = '/api/dungeon-images/bulldrome-hunting-ground-1782008229745-0b8d91e2.png', description = 'A focused hunt through Bulldrome territory.' WHERE id = 1;
|
||||||
|
UPDATE dungeons SET slug = 'yian-kut-ku-hunting-ground', name = 'Yian Kut-Ku Hunting Ground', recommended_level = 1, content_type = 'dungeon', party_size = 6, experience_reward = 125, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Yian Kut-Ku territory.' WHERE id = 2;
|
||||||
|
UPDATE dungeons SET slug = 'rathian-hunting-ground', name = 'Rathian Hunting Ground', recommended_level = 1, content_type = 'dungeon', party_size = 6, experience_reward = 125, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Rathian territory.' WHERE id = 3;
|
||||||
|
UPDATE dungeons SET slug = 'tigrex-hunting-ground', name = 'Tigrex Hunting Ground', recommended_level = 10, content_type = 'dungeon', party_size = 6, experience_reward = 205, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Tigrex territory.' WHERE id = 4;
|
||||||
|
UPDATE dungeons SET slug = 'rathalos-hunting-ground', name = 'Rathalos Hunting Ground', recommended_level = 10, content_type = 'dungeon', party_size = 6, experience_reward = 205, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Rathalos territory.' WHERE id = 5;
|
||||||
|
UPDATE dungeons SET slug = 'gypceros-hunting-ground', name = 'Gypceros Hunting Ground', recommended_level = 10, content_type = 'dungeon', party_size = 6, experience_reward = 205, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Gypceros territory.' WHERE id = 6;
|
||||||
|
UPDATE dungeons SET slug = 'nargacuga-hunting-ground', name = 'Nargacuga Hunting Ground', recommended_level = 15, content_type = 'dungeon', party_size = 6, experience_reward = 245, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Nargacuga territory.' WHERE id = 7;
|
||||||
|
UPDATE dungeons SET slug = 'azuros-hunting-ground', name = 'Azuros Hunting Ground', recommended_level = 15, content_type = 'dungeon', party_size = 6, experience_reward = 245, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Azuros territory.' WHERE id = 8;
|
||||||
|
UPDATE dungeons SET slug = 'diablos-hunting-ground', name = 'Diablos Hunting Ground', recommended_level = 15, content_type = 'dungeon', party_size = 6, experience_reward = 245, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Diablos territory.' WHERE id = 9;
|
||||||
|
UPDATE dungeons SET slug = 'barroth-hunting-ground', name = 'Barroth Hunting Ground', recommended_level = 20, content_type = 'dungeon', party_size = 6, experience_reward = 285, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Barroth territory.' WHERE id = 10;
|
||||||
|
UPDATE dungeons SET slug = 'tobi-kadachi-hunting-ground', name = 'Tobi Kadachi Hunting Ground', recommended_level = 20, content_type = 'dungeon', party_size = 6, experience_reward = 285, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Tobi Kadachi territory.' WHERE id = 11;
|
||||||
|
UPDATE dungeons SET slug = 'monoblos-hunting-ground', name = 'Monoblos Hunting Ground', recommended_level = 20, content_type = 'dungeon', party_size = 6, experience_reward = 285, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Monoblos territory.' WHERE id = 12;
|
||||||
|
UPDATE dungeons SET slug = 'anjanath-hunting-ground', name = 'Anjanath Hunting Ground', recommended_level = 25, content_type = 'dungeon', party_size = 6, experience_reward = 325, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Anjanath territory.' WHERE id = 13;
|
||||||
|
UPDATE dungeons SET slug = 'bazelgeuse-hunting-ground', name = 'Bazelgeuse Hunting Ground', recommended_level = 25, content_type = 'dungeon', party_size = 6, experience_reward = 325, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Bazelgeuse territory.' WHERE id = 14;
|
||||||
|
UPDATE dungeons SET slug = 'odogaron-hunting-ground', name = 'Odogaron Hunting Ground', recommended_level = 25, content_type = 'dungeon', party_size = 6, experience_reward = 325, image_url = '/boss-placeholder.svg', description = 'A focused hunt through Odogaron territory.' WHERE id = 15;
|
||||||
|
UPDATE dungeons SET slug = 'apex-tigrex-raid', name = 'Apex Tigrex Raid', recommended_level = 10, content_type = 'raid', party_size = 18, experience_reward = 275, image_url = '/boss-placeholder.svg', description = 'A raid-scale hunt against Apex Tigrex.' WHERE id = 20;
|
||||||
|
UPDATE dungeons SET slug = 'apex-rathalos-raid', name = 'Apex Rathalos Raid', recommended_level = 10, content_type = 'raid', party_size = 18, experience_reward = 275, image_url = '/boss-placeholder.svg', description = 'A raid-scale hunt against Apex Rathalos.' WHERE id = 21;
|
||||||
|
UPDATE dungeons SET slug = 'apex-gypceros-raid', name = 'Apex Gypceros Raid', recommended_level = 10, content_type = 'raid', party_size = 18, experience_reward = 275, image_url = '/boss-placeholder.svg', description = 'A raid-scale hunt against Apex Gypceros.' WHERE id = 22;
|
||||||
|
UPDATE dungeons SET slug = 'apex-nargacuga-raid', name = 'Apex Nargacuga Raid', recommended_level = 15, content_type = 'raid', party_size = 18, experience_reward = 325, image_url = '/boss-placeholder.svg', description = 'A raid-scale hunt against Apex Nargacuga.' WHERE id = 23;
|
||||||
|
UPDATE dungeons SET slug = 'apex-azuros-raid', name = 'Apex Azuros Raid', recommended_level = 15, content_type = 'raid', party_size = 18, experience_reward = 325, image_url = '/boss-placeholder.svg', description = 'A raid-scale hunt against Apex Azuros.' WHERE id = 24;
|
||||||
|
UPDATE dungeons SET slug = 'apex-diablos-raid', name = 'Apex Diablos Raid', recommended_level = 15, content_type = 'raid', party_size = 18, experience_reward = 325, image_url = '/boss-placeholder.svg', description = 'A raid-scale hunt against Apex Diablos.' WHERE id = 25;
|
||||||
|
UPDATE dungeons SET slug = 'apex-barroth-raid', name = 'Apex Barroth Raid', recommended_level = 20, content_type = 'raid', party_size = 18, experience_reward = 375, image_url = '/boss-placeholder.svg', description = 'A raid-scale hunt against Apex Barroth.' WHERE id = 26;
|
||||||
|
UPDATE dungeons SET slug = 'apex-tobi-kadachi-raid', name = 'Apex Tobi Kadachi Raid', recommended_level = 20, content_type = 'raid', party_size = 18, experience_reward = 375, image_url = '/boss-placeholder.svg', description = 'A raid-scale hunt against Apex Tobi Kadachi.' WHERE id = 27;
|
||||||
|
UPDATE dungeons SET slug = 'apex-monoblos-raid', name = 'Apex Monoblos Raid', recommended_level = 20, content_type = 'raid', party_size = 18, experience_reward = 375, image_url = '/boss-placeholder.svg', description = 'A raid-scale hunt against Apex Monoblos.' WHERE id = 28;
|
||||||
|
UPDATE dungeons SET slug = 'apex-anjanath-raid', name = 'Apex Anjanath Raid', recommended_level = 25, content_type = 'raid', party_size = 18, experience_reward = 425, image_url = '/boss-placeholder.svg', description = 'A raid-scale hunt against Apex Anjanath.' WHERE id = 29;
|
||||||
|
UPDATE dungeons SET slug = 'apex-bazelgeuse-raid', name = 'Apex Bazelgeuse Raid', recommended_level = 25, content_type = 'raid', party_size = 18, experience_reward = 425, image_url = '/boss-placeholder.svg', description = 'A raid-scale hunt against Apex Bazelgeuse.' WHERE id = 30;
|
||||||
|
UPDATE dungeons SET slug = 'apex-odogaron-raid', name = 'Apex Odogaron Raid', recommended_level = 25, content_type = 'raid', party_size = 18, experience_reward = 425, image_url = '/boss-placeholder.svg', description = 'A raid-scale hunt against Apex Odogaron.' WHERE id = 31;
|
||||||
|
|
||||||
|
UPDATE encounters SET slug = 'bulldrome-approach', name = 'Bulldrome Approach', encounter_type = 'trash', max_health = 465, base_damage = 14, tank_damage = 9, party_damage = 26, description = 'Hunters clear the path before Bulldrome.', image_url = '/api/boss-images/bulldrome-approach-1782009080839-e94761e7.png' WHERE id = 101;
|
||||||
|
UPDATE encounters SET slug = 'bulldrome-guardians', name = 'Bulldrome Guardians', encounter_type = 'trash', max_health = 555, base_damage = 16, tank_damage = 10, party_damage = 28, description = 'Hunters clear the path before Bulldrome.', image_url = '/api/boss-images/bulldrome-guardians-1782009078446-a2d2e266.png' WHERE id = 102;
|
||||||
|
UPDATE encounters SET slug = 'bulldrome-boss', name = 'Bulldrome', encounter_type = 'boss', max_health = 895, base_damage = 20, tank_damage = 15, party_damage = 31, description = 'Bulldrome drops boss coins for crafting.', image_url = '/api/boss-images/bulldrome-boss-1782009066079-e670cb7e.png' WHERE id = 103;
|
||||||
|
UPDATE encounters SET slug = 'yian-kut-ku-approach', name = 'Yian Kut-Ku Approach', encounter_type = 'trash', max_health = 465, base_damage = 14, tank_damage = 9, party_damage = 26, description = 'Hunters clear the path before Yian Kut-Ku.', image_url = '/boss-placeholder.svg' WHERE id = 201;
|
||||||
|
UPDATE encounters SET slug = 'yian-kut-ku-guardians', name = 'Yian Kut-Ku Guardians', encounter_type = 'trash', max_health = 555, base_damage = 16, tank_damage = 10, party_damage = 28, description = 'Hunters clear the path before Yian Kut-Ku.', image_url = '/boss-placeholder.svg' WHERE id = 202;
|
||||||
|
UPDATE encounters SET slug = 'yian-kut-ku-boss', name = 'Yian Kut-Ku', encounter_type = 'boss', max_health = 895, base_damage = 20, tank_damage = 15, party_damage = 31, description = 'Yian Kut-Ku drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 203;
|
||||||
|
UPDATE encounters SET slug = 'rathian-approach', name = 'Rathian Approach', encounter_type = 'trash', max_health = 465, base_damage = 14, tank_damage = 9, party_damage = 26, description = 'Hunters clear the path before Rathian.', image_url = '/boss-placeholder.svg' WHERE id = 301;
|
||||||
|
UPDATE encounters SET slug = 'rathian-guardians', name = 'Rathian Guardians', encounter_type = 'trash', max_health = 555, base_damage = 16, tank_damage = 10, party_damage = 28, description = 'Hunters clear the path before Rathian.', image_url = '/boss-placeholder.svg' WHERE id = 302;
|
||||||
|
UPDATE encounters SET slug = 'rathian-boss', name = 'Rathian', encounter_type = 'boss', max_health = 895, base_damage = 20, tank_damage = 15, party_damage = 31, description = 'Rathian drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 303;
|
||||||
|
UPDATE encounters SET slug = 'tigrex-approach', name = 'Tigrex Approach', encounter_type = 'trash', max_health = 780, base_damage = 15, tank_damage = 10, party_damage = 28, description = 'Hunters clear the path before Tigrex.', image_url = '/boss-placeholder.svg' WHERE id = 401;
|
||||||
|
UPDATE encounters SET slug = 'tigrex-guardians', name = 'Tigrex Guardians', encounter_type = 'trash', max_health = 870, base_damage = 17, tank_damage = 11, party_damage = 30, description = 'Hunters clear the path before Tigrex.', image_url = '/boss-placeholder.svg' WHERE id = 402;
|
||||||
|
UPDATE encounters SET slug = 'tigrex-boss', name = 'Tigrex', encounter_type = 'boss', max_health = 1210, base_damage = 21, tank_damage = 16, party_damage = 33, description = 'Tigrex drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 403;
|
||||||
|
UPDATE encounters SET slug = 'rathalos-approach', name = 'Rathalos Approach', encounter_type = 'trash', max_health = 780, base_damage = 15, tank_damage = 10, party_damage = 28, description = 'Hunters clear the path before Rathalos.', image_url = '/boss-placeholder.svg' WHERE id = 501;
|
||||||
|
UPDATE encounters SET slug = 'rathalos-guardians', name = 'Rathalos Guardians', encounter_type = 'trash', max_health = 870, base_damage = 17, tank_damage = 11, party_damage = 30, description = 'Hunters clear the path before Rathalos.', image_url = '/boss-placeholder.svg' WHERE id = 502;
|
||||||
|
UPDATE encounters SET slug = 'rathalos-boss', name = 'Rathalos', encounter_type = 'boss', max_health = 1210, base_damage = 21, tank_damage = 16, party_damage = 33, description = 'Rathalos drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 503;
|
||||||
|
UPDATE encounters SET slug = 'gypceros-approach', name = 'Gypceros Approach', encounter_type = 'trash', max_health = 780, base_damage = 15, tank_damage = 10, party_damage = 28, description = 'Hunters clear the path before Gypceros.', image_url = '/boss-placeholder.svg' WHERE id = 601;
|
||||||
|
UPDATE encounters SET slug = 'gypceros-guardians', name = 'Gypceros Guardians', encounter_type = 'trash', max_health = 870, base_damage = 17, tank_damage = 11, party_damage = 30, description = 'Hunters clear the path before Gypceros.', image_url = '/boss-placeholder.svg' WHERE id = 602;
|
||||||
|
UPDATE encounters SET slug = 'gypceros-boss', name = 'Gypceros', encounter_type = 'boss', max_health = 1210, base_damage = 21, tank_damage = 16, party_damage = 33, description = 'Gypceros drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 603;
|
||||||
|
UPDATE encounters SET slug = 'nargacuga-approach', name = 'Nargacuga Approach', encounter_type = 'trash', max_health = 955, base_damage = 16, tank_damage = 11, party_damage = 30, description = 'Hunters clear the path before Nargacuga.', image_url = '/boss-placeholder.svg' WHERE id = 701;
|
||||||
|
UPDATE encounters SET slug = 'nargacuga-guardians', name = 'Nargacuga Guardians', encounter_type = 'trash', max_health = 1045, base_damage = 18, tank_damage = 12, party_damage = 32, description = 'Hunters clear the path before Nargacuga.', image_url = '/boss-placeholder.svg' WHERE id = 702;
|
||||||
|
UPDATE encounters SET slug = 'nargacuga-boss', name = 'Nargacuga', encounter_type = 'boss', max_health = 1385, base_damage = 22, tank_damage = 17, party_damage = 35, description = 'Nargacuga drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 703;
|
||||||
|
UPDATE encounters SET slug = 'azuros-approach', name = 'Azuros Approach', encounter_type = 'trash', max_health = 955, base_damage = 16, tank_damage = 11, party_damage = 30, description = 'Hunters clear the path before Azuros.', image_url = '/boss-placeholder.svg' WHERE id = 801;
|
||||||
|
UPDATE encounters SET slug = 'azuros-guardians', name = 'Azuros Guardians', encounter_type = 'trash', max_health = 1045, base_damage = 18, tank_damage = 12, party_damage = 32, description = 'Hunters clear the path before Azuros.', image_url = '/boss-placeholder.svg' WHERE id = 802;
|
||||||
|
UPDATE encounters SET slug = 'azuros-boss', name = 'Azuros', encounter_type = 'boss', max_health = 1385, base_damage = 22, tank_damage = 17, party_damage = 35, description = 'Azuros drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 803;
|
||||||
|
UPDATE encounters SET slug = 'diablos-approach', name = 'Diablos Approach', encounter_type = 'trash', max_health = 955, base_damage = 16, tank_damage = 11, party_damage = 30, description = 'Hunters clear the path before Diablos.', image_url = '/boss-placeholder.svg' WHERE id = 901;
|
||||||
|
UPDATE encounters SET slug = 'diablos-guardians', name = 'Diablos Guardians', encounter_type = 'trash', max_health = 1045, base_damage = 18, tank_damage = 12, party_damage = 32, description = 'Hunters clear the path before Diablos.', image_url = '/boss-placeholder.svg' WHERE id = 902;
|
||||||
|
UPDATE encounters SET slug = 'diablos-boss', name = 'Diablos', encounter_type = 'boss', max_health = 1385, base_damage = 22, tank_damage = 17, party_damage = 35, description = 'Diablos drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 903;
|
||||||
|
UPDATE encounters SET slug = 'barroth-approach', name = 'Barroth Approach', encounter_type = 'trash', max_health = 1130, base_damage = 17, tank_damage = 12, party_damage = 32, description = 'Hunters clear the path before Barroth.', image_url = '/boss-placeholder.svg' WHERE id = 1001;
|
||||||
|
UPDATE encounters SET slug = 'barroth-guardians', name = 'Barroth Guardians', encounter_type = 'trash', max_health = 1220, base_damage = 19, tank_damage = 13, party_damage = 34, description = 'Hunters clear the path before Barroth.', image_url = '/boss-placeholder.svg' WHERE id = 1002;
|
||||||
|
UPDATE encounters SET slug = 'barroth-boss', name = 'Barroth', encounter_type = 'boss', max_health = 1560, base_damage = 23, tank_damage = 18, party_damage = 37, description = 'Barroth drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 1003;
|
||||||
|
UPDATE encounters SET slug = 'tobi-kadachi-approach', name = 'Tobi Kadachi Approach', encounter_type = 'trash', max_health = 1130, base_damage = 17, tank_damage = 12, party_damage = 32, description = 'Hunters clear the path before Tobi Kadachi.', image_url = '/boss-placeholder.svg' WHERE id = 1101;
|
||||||
|
UPDATE encounters SET slug = 'tobi-kadachi-guardians', name = 'Tobi Kadachi Guardians', encounter_type = 'trash', max_health = 1220, base_damage = 19, tank_damage = 13, party_damage = 34, description = 'Hunters clear the path before Tobi Kadachi.', image_url = '/boss-placeholder.svg' WHERE id = 1102;
|
||||||
|
UPDATE encounters SET slug = 'tobi-kadachi-boss', name = 'Tobi Kadachi', encounter_type = 'boss', max_health = 1560, base_damage = 23, tank_damage = 18, party_damage = 37, description = 'Tobi Kadachi drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 1103;
|
||||||
|
UPDATE encounters SET slug = 'monoblos-approach', name = 'Monoblos Approach', encounter_type = 'trash', max_health = 1130, base_damage = 17, tank_damage = 12, party_damage = 32, description = 'Hunters clear the path before Monoblos.', image_url = '/boss-placeholder.svg' WHERE id = 1201;
|
||||||
|
UPDATE encounters SET slug = 'monoblos-guardians', name = 'Monoblos Guardians', encounter_type = 'trash', max_health = 1220, base_damage = 19, tank_damage = 13, party_damage = 34, description = 'Hunters clear the path before Monoblos.', image_url = '/boss-placeholder.svg' WHERE id = 1202;
|
||||||
|
UPDATE encounters SET slug = 'monoblos-boss', name = 'Monoblos', encounter_type = 'boss', max_health = 1560, base_damage = 23, tank_damage = 18, party_damage = 37, description = 'Monoblos drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 1203;
|
||||||
|
UPDATE encounters SET slug = 'anjanath-approach', name = 'Anjanath Approach', encounter_type = 'trash', max_health = 1305, base_damage = 18, tank_damage = 13, party_damage = 34, description = 'Hunters clear the path before Anjanath.', image_url = '/boss-placeholder.svg' WHERE id = 1301;
|
||||||
|
UPDATE encounters SET slug = 'anjanath-guardians', name = 'Anjanath Guardians', encounter_type = 'trash', max_health = 1395, base_damage = 20, tank_damage = 14, party_damage = 36, description = 'Hunters clear the path before Anjanath.', image_url = '/boss-placeholder.svg' WHERE id = 1302;
|
||||||
|
UPDATE encounters SET slug = 'anjanath-boss', name = 'Anjanath', encounter_type = 'boss', max_health = 1735, base_damage = 24, tank_damage = 19, party_damage = 39, description = 'Anjanath drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 1303;
|
||||||
|
UPDATE encounters SET slug = 'bazelgeuse-approach', name = 'Bazelgeuse Approach', encounter_type = 'trash', max_health = 1305, base_damage = 18, tank_damage = 13, party_damage = 34, description = 'Hunters clear the path before Bazelgeuse.', image_url = '/boss-placeholder.svg' WHERE id = 1401;
|
||||||
|
UPDATE encounters SET slug = 'bazelgeuse-guardians', name = 'Bazelgeuse Guardians', encounter_type = 'trash', max_health = 1395, base_damage = 20, tank_damage = 14, party_damage = 36, description = 'Hunters clear the path before Bazelgeuse.', image_url = '/boss-placeholder.svg' WHERE id = 1402;
|
||||||
|
UPDATE encounters SET slug = 'bazelgeuse-boss', name = 'Bazelgeuse', encounter_type = 'boss', max_health = 1735, base_damage = 24, tank_damage = 19, party_damage = 39, description = 'Bazelgeuse drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 1403;
|
||||||
|
UPDATE encounters SET slug = 'odogaron-approach', name = 'Odogaron Approach', encounter_type = 'trash', max_health = 1305, base_damage = 18, tank_damage = 13, party_damage = 34, description = 'Hunters clear the path before Odogaron.', image_url = '/boss-placeholder.svg' WHERE id = 1501;
|
||||||
|
UPDATE encounters SET slug = 'odogaron-guardians', name = 'Odogaron Guardians', encounter_type = 'trash', max_health = 1395, base_damage = 20, tank_damage = 14, party_damage = 36, description = 'Hunters clear the path before Odogaron.', image_url = '/boss-placeholder.svg' WHERE id = 1502;
|
||||||
|
UPDATE encounters SET slug = 'odogaron-boss', name = 'Odogaron', encounter_type = 'boss', max_health = 1735, base_damage = 24, tank_damage = 19, party_damage = 39, description = 'Odogaron drops boss coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 1503;
|
||||||
|
UPDATE encounters SET slug = 'tigrex-raid-approach', name = 'Apex Tigrex Approach', encounter_type = 'trash', max_health = 1900, base_damage = 17, tank_damage = 11, party_damage = 54, description = 'Hunters clear the raid path before Apex Tigrex.', image_url = '/boss-placeholder.svg' WHERE id = 2001;
|
||||||
|
UPDATE encounters SET slug = 'tigrex-raid-guardians', name = 'Apex Tigrex Guardians', encounter_type = 'trash', max_health = 1970, base_damage = 18, tank_damage = 12, party_damage = 56, description = 'Hunters clear the raid path before Apex Tigrex.', image_url = '/boss-placeholder.svg' WHERE id = 2002;
|
||||||
|
UPDATE encounters SET slug = 'tigrex-raid-boss', name = 'Apex Tigrex', encounter_type = 'boss', max_health = 2230, base_damage = 22, tank_damage = 16, party_damage = 60, description = 'Apex Tigrex drops raid coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 2003;
|
||||||
|
UPDATE encounters SET slug = 'rathalos-raid-approach', name = 'Apex Rathalos Approach', encounter_type = 'trash', max_health = 1900, base_damage = 17, tank_damage = 11, party_damage = 54, description = 'Hunters clear the raid path before Apex Rathalos.', image_url = '/boss-placeholder.svg' WHERE id = 2101;
|
||||||
|
UPDATE encounters SET slug = 'rathalos-raid-guardians', name = 'Apex Rathalos Guardians', encounter_type = 'trash', max_health = 1970, base_damage = 18, tank_damage = 12, party_damage = 56, description = 'Hunters clear the raid path before Apex Rathalos.', image_url = '/boss-placeholder.svg' WHERE id = 2102;
|
||||||
|
UPDATE encounters SET slug = 'rathalos-raid-boss', name = 'Apex Rathalos', encounter_type = 'boss', max_health = 2230, base_damage = 22, tank_damage = 16, party_damage = 60, description = 'Apex Rathalos drops raid coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 2103;
|
||||||
|
UPDATE encounters SET slug = 'gypceros-raid-approach', name = 'Apex Gypceros Approach', encounter_type = 'trash', max_health = 1900, base_damage = 17, tank_damage = 11, party_damage = 54, description = 'Hunters clear the raid path before Apex Gypceros.', image_url = '/boss-placeholder.svg' WHERE id = 2201;
|
||||||
|
UPDATE encounters SET slug = 'gypceros-raid-guardians', name = 'Apex Gypceros Guardians', encounter_type = 'trash', max_health = 1970, base_damage = 18, tank_damage = 12, party_damage = 56, description = 'Hunters clear the raid path before Apex Gypceros.', image_url = '/boss-placeholder.svg' WHERE id = 2202;
|
||||||
|
UPDATE encounters SET slug = 'gypceros-raid-boss', name = 'Apex Gypceros', encounter_type = 'boss', max_health = 2230, base_damage = 22, tank_damage = 16, party_damage = 60, description = 'Apex Gypceros drops raid coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 2203;
|
||||||
|
UPDATE encounters SET slug = 'nargacuga-raid-approach', name = 'Apex Nargacuga Approach', encounter_type = 'trash', max_health = 2075, base_damage = 18, tank_damage = 12, party_damage = 56, description = 'Hunters clear the raid path before Apex Nargacuga.', image_url = '/boss-placeholder.svg' WHERE id = 2301;
|
||||||
|
UPDATE encounters SET slug = 'nargacuga-raid-guardians', name = 'Apex Nargacuga Guardians', encounter_type = 'trash', max_health = 2145, base_damage = 19, tank_damage = 13, party_damage = 58, description = 'Hunters clear the raid path before Apex Nargacuga.', image_url = '/boss-placeholder.svg' WHERE id = 2302;
|
||||||
|
UPDATE encounters SET slug = 'nargacuga-raid-boss', name = 'Apex Nargacuga', encounter_type = 'boss', max_health = 2405, base_damage = 23, tank_damage = 17, party_damage = 62, description = 'Apex Nargacuga drops raid coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 2303;
|
||||||
|
UPDATE encounters SET slug = 'azuros-raid-approach', name = 'Apex Azuros Approach', encounter_type = 'trash', max_health = 2075, base_damage = 18, tank_damage = 12, party_damage = 56, description = 'Hunters clear the raid path before Apex Azuros.', image_url = '/boss-placeholder.svg' WHERE id = 2401;
|
||||||
|
UPDATE encounters SET slug = 'azuros-raid-guardians', name = 'Apex Azuros Guardians', encounter_type = 'trash', max_health = 2145, base_damage = 19, tank_damage = 13, party_damage = 58, description = 'Hunters clear the raid path before Apex Azuros.', image_url = '/boss-placeholder.svg' WHERE id = 2402;
|
||||||
|
UPDATE encounters SET slug = 'azuros-raid-boss', name = 'Apex Azuros', encounter_type = 'boss', max_health = 2405, base_damage = 23, tank_damage = 17, party_damage = 62, description = 'Apex Azuros drops raid coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 2403;
|
||||||
|
UPDATE encounters SET slug = 'diablos-raid-approach', name = 'Apex Diablos Approach', encounter_type = 'trash', max_health = 2075, base_damage = 18, tank_damage = 12, party_damage = 56, description = 'Hunters clear the raid path before Apex Diablos.', image_url = '/boss-placeholder.svg' WHERE id = 2501;
|
||||||
|
UPDATE encounters SET slug = 'diablos-raid-guardians', name = 'Apex Diablos Guardians', encounter_type = 'trash', max_health = 2145, base_damage = 19, tank_damage = 13, party_damage = 58, description = 'Hunters clear the raid path before Apex Diablos.', image_url = '/boss-placeholder.svg' WHERE id = 2502;
|
||||||
|
UPDATE encounters SET slug = 'diablos-raid-boss', name = 'Apex Diablos', encounter_type = 'boss', max_health = 2405, base_damage = 23, tank_damage = 17, party_damage = 62, description = 'Apex Diablos drops raid coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 2503;
|
||||||
|
UPDATE encounters SET slug = 'barroth-raid-approach', name = 'Apex Barroth Approach', encounter_type = 'trash', max_health = 2250, base_damage = 19, tank_damage = 13, party_damage = 58, description = 'Hunters clear the raid path before Apex Barroth.', image_url = '/boss-placeholder.svg' WHERE id = 2601;
|
||||||
|
UPDATE encounters SET slug = 'barroth-raid-guardians', name = 'Apex Barroth Guardians', encounter_type = 'trash', max_health = 2320, base_damage = 20, tank_damage = 14, party_damage = 60, description = 'Hunters clear the raid path before Apex Barroth.', image_url = '/boss-placeholder.svg' WHERE id = 2602;
|
||||||
|
UPDATE encounters SET slug = 'barroth-raid-boss', name = 'Apex Barroth', encounter_type = 'boss', max_health = 2580, base_damage = 24, tank_damage = 18, party_damage = 64, description = 'Apex Barroth drops raid coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 2603;
|
||||||
|
UPDATE encounters SET slug = 'tobi-kadachi-raid-approach', name = 'Apex Tobi Kadachi Approach', encounter_type = 'trash', max_health = 2250, base_damage = 19, tank_damage = 13, party_damage = 58, description = 'Hunters clear the raid path before Apex Tobi Kadachi.', image_url = '/boss-placeholder.svg' WHERE id = 2701;
|
||||||
|
UPDATE encounters SET slug = 'tobi-kadachi-raid-guardians', name = 'Apex Tobi Kadachi Guardians', encounter_type = 'trash', max_health = 2320, base_damage = 20, tank_damage = 14, party_damage = 60, description = 'Hunters clear the raid path before Apex Tobi Kadachi.', image_url = '/boss-placeholder.svg' WHERE id = 2702;
|
||||||
|
UPDATE encounters SET slug = 'tobi-kadachi-raid-boss', name = 'Apex Tobi Kadachi', encounter_type = 'boss', max_health = 2580, base_damage = 24, tank_damage = 18, party_damage = 64, description = 'Apex Tobi Kadachi drops raid coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 2703;
|
||||||
|
UPDATE encounters SET slug = 'monoblos-raid-approach', name = 'Apex Monoblos Approach', encounter_type = 'trash', max_health = 2250, base_damage = 19, tank_damage = 13, party_damage = 58, description = 'Hunters clear the raid path before Apex Monoblos.', image_url = '/boss-placeholder.svg' WHERE id = 2801;
|
||||||
|
UPDATE encounters SET slug = 'monoblos-raid-guardians', name = 'Apex Monoblos Guardians', encounter_type = 'trash', max_health = 2320, base_damage = 20, tank_damage = 14, party_damage = 60, description = 'Hunters clear the raid path before Apex Monoblos.', image_url = '/boss-placeholder.svg' WHERE id = 2802;
|
||||||
|
UPDATE encounters SET slug = 'monoblos-raid-boss', name = 'Apex Monoblos', encounter_type = 'boss', max_health = 2580, base_damage = 24, tank_damage = 18, party_damage = 64, description = 'Apex Monoblos drops raid coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 2803;
|
||||||
|
UPDATE encounters SET slug = 'anjanath-raid-approach', name = 'Apex Anjanath Approach', encounter_type = 'trash', max_health = 2425, base_damage = 20, tank_damage = 14, party_damage = 60, description = 'Hunters clear the raid path before Apex Anjanath.', image_url = '/boss-placeholder.svg' WHERE id = 2901;
|
||||||
|
UPDATE encounters SET slug = 'anjanath-raid-guardians', name = 'Apex Anjanath Guardians', encounter_type = 'trash', max_health = 2495, base_damage = 21, tank_damage = 15, party_damage = 62, description = 'Hunters clear the raid path before Apex Anjanath.', image_url = '/boss-placeholder.svg' WHERE id = 2902;
|
||||||
|
UPDATE encounters SET slug = 'anjanath-raid-boss', name = 'Apex Anjanath', encounter_type = 'boss', max_health = 2755, base_damage = 25, tank_damage = 19, party_damage = 66, description = 'Apex Anjanath drops raid coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 2903;
|
||||||
|
UPDATE encounters SET slug = 'bazelgeuse-raid-approach', name = 'Apex Bazelgeuse Approach', encounter_type = 'trash', max_health = 2425, base_damage = 20, tank_damage = 14, party_damage = 60, description = 'Hunters clear the raid path before Apex Bazelgeuse.', image_url = '/boss-placeholder.svg' WHERE id = 3001;
|
||||||
|
UPDATE encounters SET slug = 'bazelgeuse-raid-guardians', name = 'Apex Bazelgeuse Guardians', encounter_type = 'trash', max_health = 2495, base_damage = 21, tank_damage = 15, party_damage = 62, description = 'Hunters clear the raid path before Apex Bazelgeuse.', image_url = '/boss-placeholder.svg' WHERE id = 3002;
|
||||||
|
UPDATE encounters SET slug = 'bazelgeuse-raid-boss', name = 'Apex Bazelgeuse', encounter_type = 'boss', max_health = 2755, base_damage = 25, tank_damage = 19, party_damage = 66, description = 'Apex Bazelgeuse drops raid coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 3003;
|
||||||
|
UPDATE encounters SET slug = 'odogaron-raid-approach', name = 'Apex Odogaron Approach', encounter_type = 'trash', max_health = 2425, base_damage = 20, tank_damage = 14, party_damage = 60, description = 'Hunters clear the raid path before Apex Odogaron.', image_url = '/boss-placeholder.svg' WHERE id = 3101;
|
||||||
|
UPDATE encounters SET slug = 'odogaron-raid-guardians', name = 'Apex Odogaron Guardians', encounter_type = 'trash', max_health = 2495, base_damage = 21, tank_damage = 15, party_damage = 62, description = 'Hunters clear the raid path before Apex Odogaron.', image_url = '/boss-placeholder.svg' WHERE id = 3102;
|
||||||
|
UPDATE encounters SET slug = 'odogaron-raid-boss', name = 'Apex Odogaron', encounter_type = 'boss', max_health = 2755, base_damage = 25, tank_damage = 19, party_damage = 66, description = 'Apex Odogaron drops raid coins for crafting.', image_url = '/boss-placeholder.svg' WHERE id = 3103;
|
||||||
|
|
||||||
|
UPDATE items SET slug = 'emberglass-sigil', name = 'Honed Yian Kut-Ku Ring', slot = 'ring', rarity = 'uncommon', item_level = 5, healing_power = 4, max_resource_bonus = 5, glyph = 'o', image_url = '/equipment-placeholder.svg', description = 'Crafted with Yian Kut-Ku coins.' WHERE id = 1;
|
||||||
|
UPDATE items SET slug = 'wardens-cinderwrap', name = 'Honed Bulldrome Chest', slot = 'chest', rarity = 'uncommon', item_level = 5, healing_power = 3, max_resource_bonus = 0, glyph = 'C', image_url = '/equipment-placeholder.svg', description = 'Crafted with Bulldrome coins.' WHERE id = 2;
|
||||||
|
UPDATE items SET slug = 'ashwood-crook', name = 'Honed Rathian Weapon', slot = 'weapon', rarity = 'uncommon', item_level = 5, healing_power = 5, max_resource_bonus = 0, glyph = '/', image_url = '/equipment-placeholder.svg', description = 'Crafted with Rathian coins.' WHERE id = 3;
|
||||||
|
UPDATE items SET slug = 'cinderstep-boots', name = 'Honed Yian Kut-Ku Boots', slot = 'boots', rarity = 'uncommon', item_level = 5, healing_power = 3, max_resource_bonus = 0, glyph = 'b', image_url = '/equipment-placeholder.svg', description = 'Crafted with Yian Kut-Ku coins.' WHERE id = 4;
|
||||||
|
UPDATE items SET slug = 'adepts-hood', name = 'Honed Bulldrome Helmet', slot = 'helmet', rarity = 'uncommon', item_level = 5, healing_power = 3, max_resource_bonus = 4, glyph = '^', image_url = '/equipment-placeholder.svg', description = 'Crafted with Bulldrome coins.' WHERE id = 5;
|
||||||
|
UPDATE items SET slug = 'furnace-tenders-wraps', name = 'Honed Bulldrome Gloves', slot = 'gloves', rarity = 'uncommon', item_level = 5, healing_power = 3, max_resource_bonus = 2, glyph = 'g', image_url = '/equipment-placeholder.svg', description = 'Crafted with Bulldrome coins.' WHERE id = 6;
|
||||||
|
UPDATE items SET slug = 'warden-ember', name = 'Honed Yian Kut-Ku Trinket', slot = 'trinket', rarity = 'uncommon', item_level = 5, healing_power = 4, max_resource_bonus = 4, glyph = '*', image_url = '/equipment-placeholder.svg', description = 'Crafted with Yian Kut-Ku coins.' WHERE id = 7;
|
||||||
|
UPDATE items SET slug = 'ashwalker-legwraps', name = 'Honed Rathian Pants', slot = 'pants', rarity = 'uncommon', item_level = 5, healing_power = 3, max_resource_bonus = 3, glyph = 'P', image_url = '/equipment-placeholder.svg', description = 'Crafted with Rathian coins.' WHERE id = 8;
|
||||||
|
UPDATE items SET slug = 'sootglass-pendant', name = 'Honed Rathian Necklace', slot = 'necklace', rarity = 'uncommon', item_level = 5, healing_power = 4, max_resource_bonus = 4, glyph = 'n', image_url = '/equipment-placeholder.svg', description = 'Crafted with Rathian coins.' WHERE id = 9;
|
||||||
|
UPDATE items SET slug = 'novice-crook', name = 'Raw Rathian Weapon', slot = 'weapon', rarity = 'common', item_level = 1, healing_power = 1, max_resource_bonus = 0, glyph = '/', image_url = '/equipment-placeholder.svg', description = 'Crafted with Rathian coins.' WHERE id = 100;
|
||||||
|
UPDATE items SET slug = 'novice-cowl', name = 'Raw Legacy Loot Encounter 3 Helmet', slot = 'helmet', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 1, glyph = '^', image_url = '/equipment-placeholder.svg', description = 'Crafted with Legacy Loot Encounter 3 coins.' WHERE id = 101;
|
||||||
|
UPDATE items SET slug = 'novice-vestment', name = 'Raw Legacy Loot Encounter 3 Chest', slot = 'chest', rarity = 'common', item_level = 1, healing_power = 1, max_resource_bonus = 0, glyph = 'C', image_url = '/equipment-placeholder.svg', description = 'Crafted with Legacy Loot Encounter 3 coins.' WHERE id = 102;
|
||||||
|
UPDATE items SET slug = 'novice-wraps', name = 'Raw Legacy Loot Encounter 3 Gloves', slot = 'gloves', rarity = 'common', item_level = 1, healing_power = 1, max_resource_bonus = 0, glyph = 'g', image_url = '/equipment-placeholder.svg', description = 'Crafted with Legacy Loot Encounter 3 coins.' WHERE id = 103;
|
||||||
|
UPDATE items SET slug = 'novice-slippers', name = 'Raw Yian Kut-Ku Boots', slot = 'boots', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 1, glyph = 'b', image_url = '/equipment-placeholder.svg', description = 'Crafted with Yian Kut-Ku coins.' WHERE id = 104;
|
||||||
|
UPDATE items SET slug = 'novice-band', name = 'Raw Yian Kut-Ku Ring', slot = 'ring', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 1, glyph = 'o', image_url = '/equipment-placeholder.svg', description = 'Crafted with Yian Kut-Ku coins.' WHERE id = 105;
|
||||||
|
UPDATE items SET slug = 'novice-token', name = 'Raw Yian Kut-Ku Trinket', slot = 'trinket', rarity = 'common', item_level = 1, healing_power = 1, max_resource_bonus = 0, glyph = '*', image_url = '/equipment-placeholder.svg', description = 'Crafted with Yian Kut-Ku coins.' WHERE id = 106;
|
||||||
|
UPDATE items SET slug = 'novice-wand', name = 'Novice Wand', slot = 'weapon', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 2, glyph = '!', image_url = '/equipment-placeholder.svg', description = 'A spare focus that favors a deeper resource pool.' WHERE id = 107;
|
||||||
|
UPDATE items SET slug = 'novice-trousers', name = 'Raw Rathian Pants', slot = 'pants', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 1, glyph = 'P', image_url = '/equipment-placeholder.svg', description = 'Crafted with Rathian coins.' WHERE id = 108;
|
||||||
|
UPDATE items SET slug = 'novice-pendant', name = 'Raw Rathian Necklace', slot = 'necklace', rarity = 'common', item_level = 1, healing_power = 1, max_resource_bonus = 0, glyph = 'n', image_url = '/equipment-placeholder.svg', description = 'Crafted with Rathian coins.' WHERE id = 109;
|
||||||
|
UPDATE items SET slug = 'tempered-emberglass-sigil', name = 'Green Rathalos Ring', slot = 'ring', rarity = 'uncommon', item_level = 10, healing_power = 7, max_resource_bonus = 9, glyph = 'o', image_url = '/equipment-placeholder.svg', description = 'Crafted with Rathalos coins.' WHERE id = 201;
|
||||||
|
UPDATE items SET slug = 'tempered-cinderwrap', name = 'Green Tigrex Chest', slot = 'chest', rarity = 'uncommon', item_level = 10, healing_power = 7, max_resource_bonus = 2, glyph = 'C', image_url = '/equipment-placeholder.svg', description = 'Crafted with Tigrex coins.' WHERE id = 202;
|
||||||
|
UPDATE items SET slug = 'tempered-ashwood-crook', name = 'Green Gypceros Weapon', slot = 'weapon', rarity = 'uncommon', item_level = 10, healing_power = 10, max_resource_bonus = 2, glyph = '/', image_url = '/equipment-placeholder.svg', description = 'Crafted with Gypceros coins.' WHERE id = 203;
|
||||||
|
UPDATE items SET slug = 'tempered-cinderstep-boots', name = 'Green Rathalos Boots', slot = 'boots', rarity = 'uncommon', item_level = 10, healing_power = 6, max_resource_bonus = 5, glyph = 'b', image_url = '/equipment-placeholder.svg', description = 'Crafted with Rathalos coins.' WHERE id = 204;
|
||||||
|
UPDATE items SET slug = 'tempered-adepts-hood', name = 'Green Tigrex Helmet', slot = 'helmet', rarity = 'uncommon', item_level = 10, healing_power = 6, max_resource_bonus = 6, glyph = '^', image_url = '/equipment-placeholder.svg', description = 'Crafted with Tigrex coins.' WHERE id = 205;
|
||||||
|
UPDATE items SET slug = 'tempered-furnace-wraps', name = 'Green Tigrex Gloves', slot = 'gloves', rarity = 'uncommon', item_level = 10, healing_power = 7, max_resource_bonus = 4, glyph = 'g', image_url = '/equipment-placeholder.svg', description = 'Crafted with Tigrex coins.' WHERE id = 206;
|
||||||
|
UPDATE items SET slug = 'tempered-warden-ember', name = 'Green Rathalos Trinket', slot = 'trinket', rarity = 'uncommon', item_level = 10, healing_power = 8, max_resource_bonus = 7, glyph = '*', image_url = '/equipment-placeholder.svg', description = 'Crafted with Rathalos coins.' WHERE id = 207;
|
||||||
|
UPDATE items SET slug = 'tempered-ashwalker-legwraps', name = 'Green Gypceros Pants', slot = 'pants', rarity = 'uncommon', item_level = 10, healing_power = 6, max_resource_bonus = 6, glyph = 'P', image_url = '/equipment-placeholder.svg', description = 'Crafted with Gypceros coins.' WHERE id = 208;
|
||||||
|
UPDATE items SET slug = 'tempered-sootglass-pendant', name = 'Green Gypceros Necklace', slot = 'necklace', rarity = 'uncommon', item_level = 10, healing_power = 8, max_resource_bonus = 7, glyph = 'n', image_url = '/equipment-placeholder.svg', description = 'Crafted with Gypceros coins.' WHERE id = 209;
|
||||||
|
UPDATE items SET slug = 'runed-emberglass-sigil', name = 'Blue Azuros Ring', slot = 'ring', rarity = 'rare', item_level = 15, healing_power = 10, max_resource_bonus = 13, glyph = 'o', image_url = '/equipment-placeholder.svg', description = 'Crafted with Azuros coins.' WHERE id = 301;
|
||||||
|
UPDATE items SET slug = 'runed-cinderwrap', name = 'Blue Nargacuga Chest', slot = 'chest', rarity = 'rare', item_level = 15, healing_power = 11, max_resource_bonus = 3, glyph = 'C', image_url = '/equipment-placeholder.svg', description = 'Crafted with Nargacuga coins.' WHERE id = 302;
|
||||||
|
UPDATE items SET slug = 'runed-ashwood-crook', name = 'Blue Diablos Weapon', slot = 'weapon', rarity = 'rare', item_level = 15, healing_power = 15, max_resource_bonus = 3, glyph = '/', image_url = '/equipment-placeholder.svg', description = 'Crafted with Diablos coins.' WHERE id = 303;
|
||||||
|
UPDATE items SET slug = 'runed-cinderstep-boots', name = 'Blue Azuros Boots', slot = 'boots', rarity = 'rare', item_level = 15, healing_power = 9, max_resource_bonus = 8, glyph = 'b', image_url = '/equipment-placeholder.svg', description = 'Crafted with Azuros coins.' WHERE id = 304;
|
||||||
|
UPDATE items SET slug = 'runed-adepts-hood', name = 'Blue Nargacuga Helmet', slot = 'helmet', rarity = 'rare', item_level = 15, healing_power = 9, max_resource_bonus = 9, glyph = '^', image_url = '/equipment-placeholder.svg', description = 'Crafted with Nargacuga coins.' WHERE id = 305;
|
||||||
|
UPDATE items SET slug = 'runed-furnace-wraps', name = 'Blue Nargacuga Gloves', slot = 'gloves', rarity = 'rare', item_level = 15, healing_power = 11, max_resource_bonus = 6, glyph = 'g', image_url = '/equipment-placeholder.svg', description = 'Crafted with Nargacuga coins.' WHERE id = 306;
|
||||||
|
UPDATE items SET slug = 'runed-warden-ember', name = 'Blue Azuros Trinket', slot = 'trinket', rarity = 'rare', item_level = 15, healing_power = 12, max_resource_bonus = 10, glyph = '*', image_url = '/equipment-placeholder.svg', description = 'Crafted with Azuros coins.' WHERE id = 307;
|
||||||
|
UPDATE items SET slug = 'runed-ashwalker-legwraps', name = 'Blue Diablos Pants', slot = 'pants', rarity = 'rare', item_level = 15, healing_power = 9, max_resource_bonus = 9, glyph = 'P', image_url = '/equipment-placeholder.svg', description = 'Crafted with Diablos coins.' WHERE id = 308;
|
||||||
|
UPDATE items SET slug = 'runed-sootglass-pendant', name = 'Blue Diablos Necklace', slot = 'necklace', rarity = 'rare', item_level = 15, healing_power = 12, max_resource_bonus = 10, glyph = 'n', image_url = '/equipment-placeholder.svg', description = 'Crafted with Diablos coins.' WHERE id = 309;
|
||||||
|
UPDATE items SET slug = 'mythic-emberglass-sigil', name = 'Purple Tobi Kadachi Ring', slot = 'ring', rarity = 'epic', item_level = 20, healing_power = 14, max_resource_bonus = 17, glyph = 'o', image_url = '/equipment-placeholder.svg', description = 'Crafted with Tobi Kadachi coins.' WHERE id = 401;
|
||||||
|
UPDATE items SET slug = 'mythic-cinderwrap', name = 'Purple Barroth Chest', slot = 'chest', rarity = 'epic', item_level = 20, healing_power = 15, max_resource_bonus = 4, glyph = 'C', image_url = '/equipment-placeholder.svg', description = 'Crafted with Barroth coins.' WHERE id = 402;
|
||||||
|
UPDATE items SET slug = 'mythic-ashwood-crook', name = 'Purple Monoblos Weapon', slot = 'weapon', rarity = 'epic', item_level = 20, healing_power = 20, max_resource_bonus = 4, glyph = '/', image_url = '/equipment-placeholder.svg', description = 'Crafted with Monoblos coins.' WHERE id = 403;
|
||||||
|
UPDATE items SET slug = 'mythic-cinderstep-boots', name = 'Purple Tobi Kadachi Boots', slot = 'boots', rarity = 'epic', item_level = 20, healing_power = 12, max_resource_bonus = 11, glyph = 'b', image_url = '/equipment-placeholder.svg', description = 'Crafted with Tobi Kadachi coins.' WHERE id = 404;
|
||||||
|
UPDATE items SET slug = 'mythic-adepts-hood', name = 'Purple Barroth Helmet', slot = 'helmet', rarity = 'epic', item_level = 20, healing_power = 12, max_resource_bonus = 12, glyph = '^', image_url = '/equipment-placeholder.svg', description = 'Crafted with Barroth coins.' WHERE id = 405;
|
||||||
|
UPDATE items SET slug = 'mythic-furnace-wraps', name = 'Purple Barroth Gloves', slot = 'gloves', rarity = 'epic', item_level = 20, healing_power = 15, max_resource_bonus = 8, glyph = 'g', image_url = '/equipment-placeholder.svg', description = 'Crafted with Barroth coins.' WHERE id = 406;
|
||||||
|
UPDATE items SET slug = 'mythic-warden-ember', name = 'Purple Tobi Kadachi Trinket', slot = 'trinket', rarity = 'epic', item_level = 20, healing_power = 16, max_resource_bonus = 13, glyph = '*', image_url = '/equipment-placeholder.svg', description = 'Crafted with Tobi Kadachi coins.' WHERE id = 407;
|
||||||
|
UPDATE items SET slug = 'mythic-ashwalker-legwraps', name = 'Purple Monoblos Pants', slot = 'pants', rarity = 'epic', item_level = 20, healing_power = 12, max_resource_bonus = 12, glyph = 'P', image_url = '/equipment-placeholder.svg', description = 'Crafted with Monoblos coins.' WHERE id = 408;
|
||||||
|
UPDATE items SET slug = 'mythic-sootglass-pendant', name = 'Purple Monoblos Necklace', slot = 'necklace', rarity = 'epic', item_level = 20, healing_power = 16, max_resource_bonus = 13, glyph = 'n', image_url = '/equipment-placeholder.svg', description = 'Crafted with Monoblos coins.' WHERE id = 409;
|
||||||
|
UPDATE items SET slug = 'ascendant-emberglass-sigil', name = 'Orange Bazelgeuse Ring', slot = 'ring', rarity = 'legendary', item_level = 25, healing_power = 18, max_resource_bonus = 21, glyph = 'o', image_url = '/equipment-placeholder.svg', description = 'Crafted with Bazelgeuse coins.' WHERE id = 501;
|
||||||
|
UPDATE items SET slug = 'ascendant-cinderwrap', name = 'Orange Anjanath Chest', slot = 'chest', rarity = 'legendary', item_level = 25, healing_power = 19, max_resource_bonus = 5, glyph = 'C', image_url = '/equipment-placeholder.svg', description = 'Crafted with Anjanath coins.' WHERE id = 502;
|
||||||
|
UPDATE items SET slug = 'ascendant-ashwood-crook', name = 'Orange Odogaron Weapon', slot = 'weapon', rarity = 'legendary', item_level = 25, healing_power = 25, max_resource_bonus = 5, glyph = '/', image_url = '/equipment-placeholder.svg', description = 'Crafted with Odogaron coins.' WHERE id = 503;
|
||||||
|
UPDATE items SET slug = 'ascendant-cinderstep-boots', name = 'Orange Bazelgeuse Boots', slot = 'boots', rarity = 'legendary', item_level = 25, healing_power = 15, max_resource_bonus = 14, glyph = 'b', image_url = '/equipment-placeholder.svg', description = 'Crafted with Bazelgeuse coins.' WHERE id = 504;
|
||||||
|
UPDATE items SET slug = 'ascendant-adepts-hood', name = 'Orange Anjanath Helmet', slot = 'helmet', rarity = 'legendary', item_level = 25, healing_power = 15, max_resource_bonus = 15, glyph = '^', image_url = '/equipment-placeholder.svg', description = 'Crafted with Anjanath coins.' WHERE id = 505;
|
||||||
|
UPDATE items SET slug = 'ascendant-furnace-wraps', name = 'Orange Anjanath Gloves', slot = 'gloves', rarity = 'legendary', item_level = 25, healing_power = 19, max_resource_bonus = 10, glyph = 'g', image_url = '/equipment-placeholder.svg', description = 'Crafted with Anjanath coins.' WHERE id = 506;
|
||||||
|
UPDATE items SET slug = 'ascendant-warden-ember', name = 'Orange Bazelgeuse Trinket', slot = 'trinket', rarity = 'legendary', item_level = 25, healing_power = 20, max_resource_bonus = 16, glyph = '*', image_url = '/equipment-placeholder.svg', description = 'Crafted with Bazelgeuse coins.' WHERE id = 507;
|
||||||
|
UPDATE items SET slug = 'ascendant-ashwalker-legwraps', name = 'Orange Odogaron Pants', slot = 'pants', rarity = 'legendary', item_level = 25, healing_power = 15, max_resource_bonus = 15, glyph = 'P', image_url = '/equipment-placeholder.svg', description = 'Crafted with Odogaron coins.' WHERE id = 508;
|
||||||
|
UPDATE items SET slug = 'ascendant-sootglass-pendant', name = 'Orange Odogaron Necklace', slot = 'necklace', rarity = 'legendary', item_level = 25, healing_power = 20, max_resource_bonus = 16, glyph = 'n', image_url = '/equipment-placeholder.svg', description = 'Crafted with Odogaron coins.' WHERE id = 509;
|
||||||
|
UPDATE items SET slug = 'minor-component', name = 'Minor Component', slot = 'component', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 0, glyph = '◆', image_url = '/equipment-placeholder.svg', description = 'A basic crafting component.' WHERE id = 600;
|
||||||
|
UPDATE items SET slug = 'basic-component', name = 'Basic Component', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '◇', image_url = '/equipment-placeholder.svg', description = 'A standard crafting component.' WHERE id = 601;
|
||||||
|
UPDATE items SET slug = 'refined-component', name = 'Refined Component', slot = 'component', rarity = 'common', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '◈', image_url = '/equipment-placeholder.svg', description = 'A refined crafting component.' WHERE id = 602;
|
||||||
|
UPDATE items SET slug = 'advanced-component', name = 'Advanced Component', slot = 'component', rarity = 'common', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '◉', image_url = '/equipment-placeholder.svg', description = 'An advanced crafting component.' WHERE id = 603;
|
||||||
|
UPDATE items SET slug = 'superior-component', name = 'Superior Component', slot = 'component', rarity = 'common', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '◎', image_url = '/equipment-placeholder.svg', description = 'A superior crafting component.' WHERE id = 604;
|
||||||
|
UPDATE items SET slug = 'primal-component', name = 'Primal Component', slot = 'component', rarity = 'common', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '✦', image_url = '/equipment-placeholder.svg', description = 'A primal crafting component.' WHERE id = 605;
|
||||||
|
UPDATE items SET slug = 'caldera-signet', name = 'Caldera Signet', slot = 'ring', rarity = 'rare', item_level = 7, healing_power = 5, max_resource_bonus = 6, glyph = 'o', image_url = '/equipment-placeholder.svg', description = 'A raid-forged signet warm with caldera light.' WHERE id = 701;
|
||||||
|
UPDATE items SET slug = 'vanguard-mantle', name = 'Vanguard Mantle', slot = 'chest', rarity = 'rare', item_level = 7, healing_power = 5, max_resource_bonus = 1, glyph = 'C', image_url = '/equipment-placeholder.svg', description = 'A reinforced mantle taken from the citadel vanguard.' WHERE id = 702;
|
||||||
|
UPDATE items SET slug = 'pyrebinder-crook', name = 'Pyrebinder Crook', slot = 'weapon', rarity = 'rare', item_level = 7, healing_power = 7, max_resource_bonus = 1, glyph = '/', image_url = '/equipment-placeholder.svg', description = 'A focus used to bend living flame toward restoration.' WHERE id = 703;
|
||||||
|
UPDATE items SET slug = 'emberstep-treads', name = 'Emberstep Treads', slot = 'boots', rarity = 'rare', item_level = 7, healing_power = 4, max_resource_bonus = 5, glyph = 'b', image_url = '/equipment-placeholder.svg', description = 'Raid treads made for crossing unstable volcanic stone.' WHERE id = 704;
|
||||||
|
UPDATE items SET slug = 'gatekeeper-cowl', name = 'Gatekeeper Cowl', slot = 'helmet', rarity = 'rare', item_level = 7, healing_power = 4, max_resource_bonus = 6, glyph = '^', image_url = '/equipment-placeholder.svg', description = 'A cowl inscribed with the wards of the outer gate.' WHERE id = 705;
|
||||||
|
UPDATE items SET slug = 'crownward-wraps', name = 'Crownward Wraps', slot = 'gloves', rarity = 'rare', item_level = 7, healing_power = 5, max_resource_bonus = 3, glyph = 'g', image_url = '/equipment-placeholder.svg', description = 'Precise wraps worn by the healers of the Ember Crown.' WHERE id = 706;
|
||||||
|
UPDATE items SET slug = 'living-coal-charm', name = 'Living Coal Charm', slot = 'trinket', rarity = 'rare', item_level = 7, healing_power = 6, max_resource_bonus = 5, glyph = '*', image_url = '/equipment-placeholder.svg', description = 'A coal that pulses in time with nearby heartbeats.' WHERE id = 707;
|
||||||
|
UPDATE items SET slug = 'caldera-legwraps', name = 'Caldera Legwraps', slot = 'pants', rarity = 'rare', item_level = 7, healing_power = 4, max_resource_bonus = 6, glyph = 'P', image_url = '/equipment-placeholder.svg', description = 'Raid legwraps made for unstable volcanic stone.' WHERE id = 708;
|
||||||
|
UPDATE items SET slug = 'gateflame-pendant', name = 'Gateflame Pendant', slot = 'necklace', rarity = 'rare', item_level = 7, healing_power = 6, max_resource_bonus = 5, glyph = 'n', image_url = '/equipment-placeholder.svg', description = 'A pendant bearing a cooled mote of gateflame.' WHERE id = 709;
|
||||||
|
UPDATE items SET slug = 'royal-caldera-signet', name = 'Green Apex Rathalos Ring', slot = 'ring', rarity = 'uncommon', item_level = 10, healing_power = 8, max_resource_bonus = 9, glyph = 'o', image_url = '/equipment-placeholder.svg', description = 'Crafted with Apex Rathalos coins.' WHERE id = 710;
|
||||||
|
UPDATE items SET slug = 'ember-crown-vestment', name = 'Green Apex Tigrex Chest', slot = 'chest', rarity = 'uncommon', item_level = 10, healing_power = 8, max_resource_bonus = 2, glyph = 'C', image_url = '/equipment-placeholder.svg', description = 'Crafted with Apex Tigrex coins.' WHERE id = 711;
|
||||||
|
UPDATE items SET slug = 'crownshard-crook', name = 'Green Apex Gypceros Weapon', slot = 'weapon', rarity = 'uncommon', item_level = 10, healing_power = 11, max_resource_bonus = 2, glyph = '/', image_url = '/equipment-placeholder.svg', description = 'Crafted with Apex Gypceros coins.' WHERE id = 712;
|
||||||
|
UPDATE items SET slug = 'caldera-walkers', name = 'Green Apex Rathalos Boots', slot = 'boots', rarity = 'uncommon', item_level = 10, healing_power = 7, max_resource_bonus = 8, glyph = 'b', image_url = '/equipment-placeholder.svg', description = 'Crafted with Apex Rathalos coins.' WHERE id = 713;
|
||||||
|
UPDATE items SET slug = 'inquisitors-cowl', name = 'Green Apex Tigrex Helmet', slot = 'helmet', rarity = 'uncommon', item_level = 10, healing_power = 7, max_resource_bonus = 9, glyph = '^', image_url = '/equipment-placeholder.svg', description = 'Crafted with Apex Tigrex coins.' WHERE id = 714;
|
||||||
|
UPDATE items SET slug = 'royal-flame-wraps', name = 'Green Apex Tigrex Gloves', slot = 'gloves', rarity = 'uncommon', item_level = 10, healing_power = 8, max_resource_bonus = 6, glyph = 'g', image_url = '/equipment-placeholder.svg', description = 'Crafted with Apex Tigrex coins.' WHERE id = 715;
|
||||||
|
UPDATE items SET slug = 'extinguished-crown', name = 'Green Apex Rathalos Trinket', slot = 'trinket', rarity = 'uncommon', item_level = 10, healing_power = 9, max_resource_bonus = 8, glyph = '*', image_url = '/equipment-placeholder.svg', description = 'Crafted with Apex Rathalos coins.' WHERE id = 716;
|
||||||
|
UPDATE items SET slug = 'royal-caldera-legwraps', name = 'Green Apex Gypceros Pants', slot = 'pants', rarity = 'uncommon', item_level = 10, healing_power = 7, max_resource_bonus = 9, glyph = 'P', image_url = '/equipment-placeholder.svg', description = 'Crafted with Apex Gypceros coins.' WHERE id = 717;
|
||||||
|
UPDATE items SET slug = 'ember-crown-pendant', name = 'Green Apex Gypceros Necklace', slot = 'necklace', rarity = 'uncommon', item_level = 10, healing_power = 9, max_resource_bonus = 8, glyph = 'n', image_url = '/equipment-placeholder.svg', description = 'Crafted with Apex Gypceros coins.' WHERE id = 718;
|
||||||
|
UPDATE items SET slug = 'ashen-cowl-pattern', name = 'Ashen Cowl Pattern', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = 'H', image_url = '/equipment-placeholder.svg', description = 'A Warden Vhal pattern used to craft helmets.' WHERE id = 800;
|
||||||
|
UPDATE items SET slug = 'ashen-vestment-pattern', name = 'Ashen Vestment Pattern', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = 'C', image_url = '/equipment-placeholder.svg', description = 'A Warden Vhal pattern used to craft chest pieces.' WHERE id = 801;
|
||||||
|
UPDATE items SET slug = 'ashen-wrap-pattern', name = 'Ashen Wrap Pattern', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = 'G', image_url = '/equipment-placeholder.svg', description = 'A Warden Vhal pattern used to craft gloves.' WHERE id = 802;
|
||||||
|
UPDATE items SET slug = 'cinderstep-pattern', name = 'Cinderstep Pattern', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = 'B', image_url = '/equipment-placeholder.svg', description = 'A Forge-Priestess Haela pattern used to craft boots.' WHERE id = 803;
|
||||||
|
UPDATE items SET slug = 'emberglass-setting', name = 'Emberglass Setting', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = 'R', image_url = '/equipment-placeholder.svg', description = 'A Forge-Priestess Haela setting used to craft rings.' WHERE id = 804;
|
||||||
|
UPDATE items SET slug = 'warden-ember-core', name = 'Warden Ember Core', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = 'T', image_url = '/equipment-placeholder.svg', description = 'A Forge-Priestess Haela core used to craft trinkets.' WHERE id = 805;
|
||||||
|
UPDATE items SET slug = 'ashwood-focus-pattern', name = 'Ashwood Focus Pattern', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = 'W', image_url = '/equipment-placeholder.svg', description = 'An Old Furnace pattern used to craft weapons.' WHERE id = 806;
|
||||||
|
UPDATE items SET slug = 'furnace-legwrap-pattern', name = 'Furnace Legwrap Pattern', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = 'L', image_url = '/equipment-placeholder.svg', description = 'An Old Furnace pattern used to craft pants.' WHERE id = 807;
|
||||||
|
UPDATE items SET slug = 'sootglass-pendant-pattern', name = 'Sootglass Pendant Pattern', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = 'N', image_url = '/equipment-placeholder.svg', description = 'An Old Furnace pattern used to craft necklaces.' WHERE id = 808;
|
||||||
|
UPDATE items SET slug = 'vhal-emberplate', name = 'Vhal Emberplate', slot = 'component', rarity = 'uncommon', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = 'V', image_url = '/equipment-placeholder.svg', description = 'A boss material from Warden Vhal.' WHERE id = 820;
|
||||||
|
UPDATE items SET slug = 'haela-forgebrand', name = 'Haela Forgebrand', slot = 'component', rarity = 'uncommon', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = 'F', image_url = '/equipment-placeholder.svg', description = 'A boss material from Forge-Priestess Haela.' WHERE id = 821;
|
||||||
|
UPDATE items SET slug = 'old-furnace-heartshard', name = 'Old Furnace Heartshard', slot = 'component', rarity = 'uncommon', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = 'O', image_url = '/equipment-placeholder.svg', description = 'A boss material from the Old Furnace.' WHERE id = 822;
|
||||||
|
UPDATE items SET slug = 'gatekeeper-cowl-pattern', name = 'Gatekeeper Cowl Pattern', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = 'H', image_url = '/equipment-placeholder.svg', description = 'A Gatekeeper Arkon pattern used to craft raid helmets.' WHERE id = 830;
|
||||||
|
UPDATE items SET slug = 'crownward-vestment-pattern', name = 'Crownward Vestment Pattern', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = 'C', image_url = '/equipment-placeholder.svg', description = 'A Gatekeeper Arkon pattern used to craft raid chest pieces.' WHERE id = 831;
|
||||||
|
UPDATE items SET slug = 'crownward-wrap-pattern', name = 'Crownward Wrap Pattern', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = 'G', image_url = '/equipment-placeholder.svg', description = 'A Gatekeeper Arkon pattern used to craft raid gloves.' WHERE id = 832;
|
||||||
|
UPDATE items SET slug = 'caldera-tread-pattern', name = 'Caldera Tread Pattern', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = 'B', image_url = '/equipment-placeholder.svg', description = 'A High Inquisitor Vael pattern used to craft raid boots.' WHERE id = 833;
|
||||||
|
UPDATE items SET slug = 'royal-signet-setting', name = 'Royal Signet Setting', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = 'R', image_url = '/equipment-placeholder.svg', description = 'A High Inquisitor Vael setting used to craft raid rings.' WHERE id = 834;
|
||||||
|
UPDATE items SET slug = 'living-coal-vessel', name = 'Living Coal Vessel', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = 'T', image_url = '/equipment-placeholder.svg', description = 'A High Inquisitor Vael vessel used to craft raid trinkets.' WHERE id = 835;
|
||||||
|
UPDATE items SET slug = 'crownshard-focus-pattern', name = 'Crownshard Focus Pattern', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = 'W', image_url = '/equipment-placeholder.svg', description = 'An Ember Crown pattern used to craft raid weapons.' WHERE id = 836;
|
||||||
|
UPDATE items SET slug = 'inquisitor-legwrap-pattern', name = 'Inquisitor Legwrap Pattern', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = 'L', image_url = '/equipment-placeholder.svg', description = 'An Ember Crown pattern used to craft raid pants.' WHERE id = 837;
|
||||||
|
UPDATE items SET slug = 'crown-pendant-pattern', name = 'Crown Pendant Pattern', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = 'N', image_url = '/equipment-placeholder.svg', description = 'An Ember Crown pattern used to craft raid necklaces.' WHERE id = 838;
|
||||||
|
UPDATE items SET slug = 'arkon-gate-sigil', name = 'Arkon Gate Sigil', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = 'A', image_url = '/equipment-placeholder.svg', description = 'A raid boss material from Gatekeeper Arkon.' WHERE id = 850;
|
||||||
|
UPDATE items SET slug = 'vael-brandseal', name = 'Vael Brandseal', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = 'I', image_url = '/equipment-placeholder.svg', description = 'A raid boss material from High Inquisitor Vael.' WHERE id = 851;
|
||||||
|
UPDATE items SET slug = 'ember-crown-shard', name = 'Ember Crown Shard', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = 'E', image_url = '/equipment-placeholder.svg', description = 'A raid boss material from the Ember Crown.' WHERE id = 852;
|
||||||
|
UPDATE items SET slug = 'bulldrome-drop-1', name = 'Bulldrome Drop 1', slot = 'component', rarity = 'uncommon', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Bulldrome.' WHERE id = 860;
|
||||||
|
UPDATE items SET slug = 'bulldrome-drop-2', name = 'Bulldrome Drop 2', slot = 'component', rarity = 'rare', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Bulldrome.' WHERE id = 861;
|
||||||
|
UPDATE items SET slug = 'bulldrome-drop-3', name = 'Bulldrome Drop 3', slot = 'component', rarity = 'uncommon', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Bulldrome.' WHERE id = 862;
|
||||||
|
UPDATE items SET slug = 'bulldrome-drop-4', name = 'Bulldrome Drop 4', slot = 'component', rarity = 'uncommon', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Bulldrome.' WHERE id = 863;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-drop-1', name = 'Yian Kut-Ku Drop 1', slot = 'component', rarity = 'uncommon', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Yian Kut-Ku.' WHERE id = 864;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-drop-2', name = 'Yian Kut-Ku Drop 2', slot = 'component', rarity = 'uncommon', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Yian Kut-Ku.' WHERE id = 865;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-drop-3', name = 'Yian Kut-Ku Drop 3', slot = 'component', rarity = 'uncommon', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Yian Kut-Ku.' WHERE id = 866;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-drop-4', name = 'Yian Kut-Ku Drop 4', slot = 'component', rarity = 'rare', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Yian Kut-Ku.' WHERE id = 867;
|
||||||
|
UPDATE items SET slug = 'rathian-drop-1', name = 'Rathian Drop 1', slot = 'component', rarity = 'uncommon', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Rathian.' WHERE id = 868;
|
||||||
|
UPDATE items SET slug = 'rathian-drop-2', name = 'Rathian Drop 2', slot = 'component', rarity = 'uncommon', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Rathian.' WHERE id = 869;
|
||||||
|
UPDATE items SET slug = 'rathian-drop-3', name = 'Rathian Drop 3', slot = 'component', rarity = 'rare', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Rathian.' WHERE id = 870;
|
||||||
|
UPDATE items SET slug = 'rathian-drop-4', name = 'Rathian Drop 4', slot = 'component', rarity = 'epic', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Rathian.' WHERE id = 871;
|
||||||
|
UPDATE items SET slug = 'tigrex-drop-1-ilvl-10', name = 'Tigrex Drop 1', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Tigrex.' WHERE id = 3101;
|
||||||
|
UPDATE items SET slug = 'tigrex-drop-2-ilvl-10', name = 'Tigrex Drop 2', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Tigrex.' WHERE id = 3102;
|
||||||
|
UPDATE items SET slug = 'tigrex-drop-3-ilvl-10', name = 'Tigrex Drop 3', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Tigrex.' WHERE id = 3103;
|
||||||
|
UPDATE items SET slug = 'tigrex-drop-4-ilvl-10', name = 'Tigrex Drop 4', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Tigrex.' WHERE id = 3104;
|
||||||
|
UPDATE items SET slug = 'rathalos-drop-1-ilvl-10', name = 'Rathalos Drop 1', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Rathalos.' WHERE id = 3111;
|
||||||
|
UPDATE items SET slug = 'rathalos-drop-2-ilvl-10', name = 'Rathalos Drop 2', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Rathalos.' WHERE id = 3112;
|
||||||
|
UPDATE items SET slug = 'rathalos-drop-3-ilvl-10', name = 'Rathalos Drop 3', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Rathalos.' WHERE id = 3113;
|
||||||
|
UPDATE items SET slug = 'rathalos-drop-4-ilvl-10', name = 'Rathalos Drop 4', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Rathalos.' WHERE id = 3114;
|
||||||
|
UPDATE items SET slug = 'gypceros-drop-1-ilvl-10', name = 'Gypceros Drop 1', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Gypceros.' WHERE id = 3121;
|
||||||
|
UPDATE items SET slug = 'gypceros-drop-2-ilvl-10', name = 'Gypceros Drop 2', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Gypceros.' WHERE id = 3122;
|
||||||
|
UPDATE items SET slug = 'gypceros-drop-3-ilvl-10', name = 'Gypceros Drop 3', slot = 'component', rarity = 'rare', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Gypceros.' WHERE id = 3123;
|
||||||
|
UPDATE items SET slug = 'gypceros-drop-4-ilvl-10', name = 'Gypceros Drop 4', slot = 'component', rarity = 'epic', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Gypceros.' WHERE id = 3124;
|
||||||
|
UPDATE items SET slug = 'nargacuga-drop-1-ilvl-15', name = 'Nargacuga Drop 1', slot = 'component', rarity = 'uncommon', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Nargacuga.' WHERE id = 3181;
|
||||||
|
UPDATE items SET slug = 'nargacuga-drop-2-ilvl-15', name = 'Nargacuga Drop 2', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Nargacuga.' WHERE id = 3182;
|
||||||
|
UPDATE items SET slug = 'nargacuga-drop-3-ilvl-15', name = 'Nargacuga Drop 3', slot = 'component', rarity = 'uncommon', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Nargacuga.' WHERE id = 3183;
|
||||||
|
UPDATE items SET slug = 'nargacuga-drop-4-ilvl-15', name = 'Nargacuga Drop 4', slot = 'component', rarity = 'uncommon', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Nargacuga.' WHERE id = 3184;
|
||||||
|
UPDATE items SET slug = 'azuros-drop-1-ilvl-15', name = 'Azuros Drop 1', slot = 'component', rarity = 'uncommon', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Azuros.' WHERE id = 3191;
|
||||||
|
UPDATE items SET slug = 'azuros-drop-2-ilvl-15', name = 'Azuros Drop 2', slot = 'component', rarity = 'uncommon', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Azuros.' WHERE id = 3192;
|
||||||
|
UPDATE items SET slug = 'azuros-drop-3-ilvl-15', name = 'Azuros Drop 3', slot = 'component', rarity = 'uncommon', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Azuros.' WHERE id = 3193;
|
||||||
|
UPDATE items SET slug = 'azuros-drop-4-ilvl-15', name = 'Azuros Drop 4', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Azuros.' WHERE id = 3194;
|
||||||
|
UPDATE items SET slug = 'diablos-drop-1-ilvl-15', name = 'Diablos Drop 1', slot = 'component', rarity = 'uncommon', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Diablos.' WHERE id = 3201;
|
||||||
|
UPDATE items SET slug = 'diablos-drop-2-ilvl-15', name = 'Diablos Drop 2', slot = 'component', rarity = 'uncommon', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Diablos.' WHERE id = 3202;
|
||||||
|
UPDATE items SET slug = 'diablos-drop-3-ilvl-15', name = 'Diablos Drop 3', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Diablos.' WHERE id = 3203;
|
||||||
|
UPDATE items SET slug = 'diablos-drop-4-ilvl-15', name = 'Diablos Drop 4', slot = 'component', rarity = 'epic', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Diablos.' WHERE id = 3204;
|
||||||
|
UPDATE items SET slug = 'barroth-drop-1-ilvl-20', name = 'Barroth Drop 1', slot = 'component', rarity = 'uncommon', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Barroth.' WHERE id = 3261;
|
||||||
|
UPDATE items SET slug = 'barroth-drop-2-ilvl-20', name = 'Barroth Drop 2', slot = 'component', rarity = 'rare', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Barroth.' WHERE id = 3262;
|
||||||
|
UPDATE items SET slug = 'barroth-drop-3-ilvl-20', name = 'Barroth Drop 3', slot = 'component', rarity = 'uncommon', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Barroth.' WHERE id = 3263;
|
||||||
|
UPDATE items SET slug = 'barroth-drop-4-ilvl-20', name = 'Barroth Drop 4', slot = 'component', rarity = 'uncommon', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Barroth.' WHERE id = 3264;
|
||||||
|
UPDATE items SET slug = 'tobi-kadachi-drop-1-ilvl-20', name = 'Tobi Kadachi Drop 1', slot = 'component', rarity = 'uncommon', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Tobi Kadachi.' WHERE id = 3271;
|
||||||
|
UPDATE items SET slug = 'tobi-kadachi-drop-2-ilvl-20', name = 'Tobi Kadachi Drop 2', slot = 'component', rarity = 'uncommon', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Tobi Kadachi.' WHERE id = 3272;
|
||||||
|
UPDATE items SET slug = 'tobi-kadachi-drop-3-ilvl-20', name = 'Tobi Kadachi Drop 3', slot = 'component', rarity = 'uncommon', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Tobi Kadachi.' WHERE id = 3273;
|
||||||
|
UPDATE items SET slug = 'tobi-kadachi-drop-4-ilvl-20', name = 'Tobi Kadachi Drop 4', slot = 'component', rarity = 'rare', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Tobi Kadachi.' WHERE id = 3274;
|
||||||
|
UPDATE items SET slug = 'monoblos-drop-1-ilvl-20', name = 'Monoblos Drop 1', slot = 'component', rarity = 'uncommon', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Monoblos.' WHERE id = 3281;
|
||||||
|
UPDATE items SET slug = 'monoblos-drop-2-ilvl-20', name = 'Monoblos Drop 2', slot = 'component', rarity = 'uncommon', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Monoblos.' WHERE id = 3282;
|
||||||
|
UPDATE items SET slug = 'monoblos-drop-3-ilvl-20', name = 'Monoblos Drop 3', slot = 'component', rarity = 'rare', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Monoblos.' WHERE id = 3283;
|
||||||
|
UPDATE items SET slug = 'monoblos-drop-4-ilvl-20', name = 'Monoblos Drop 4', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Monoblos.' WHERE id = 3284;
|
||||||
|
UPDATE items SET slug = 'anjanath-drop-1-ilvl-25', name = 'Anjanath Drop 1', slot = 'component', rarity = 'uncommon', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Anjanath.' WHERE id = 3341;
|
||||||
|
UPDATE items SET slug = 'anjanath-drop-2-ilvl-25', name = 'Anjanath Drop 2', slot = 'component', rarity = 'rare', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Anjanath.' WHERE id = 3342;
|
||||||
|
UPDATE items SET slug = 'anjanath-drop-3-ilvl-25', name = 'Anjanath Drop 3', slot = 'component', rarity = 'uncommon', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Anjanath.' WHERE id = 3343;
|
||||||
|
UPDATE items SET slug = 'anjanath-drop-4-ilvl-25', name = 'Anjanath Drop 4', slot = 'component', rarity = 'uncommon', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Anjanath.' WHERE id = 3344;
|
||||||
|
UPDATE items SET slug = 'bazelgeuse-drop-1-ilvl-25', name = 'Bazelgeuse Drop 1', slot = 'component', rarity = 'uncommon', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Bazelgeuse.' WHERE id = 3351;
|
||||||
|
UPDATE items SET slug = 'bazelgeuse-drop-2-ilvl-25', name = 'Bazelgeuse Drop 2', slot = 'component', rarity = 'uncommon', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Bazelgeuse.' WHERE id = 3352;
|
||||||
|
UPDATE items SET slug = 'bazelgeuse-drop-3-ilvl-25', name = 'Bazelgeuse Drop 3', slot = 'component', rarity = 'uncommon', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Bazelgeuse.' WHERE id = 3353;
|
||||||
|
UPDATE items SET slug = 'bazelgeuse-drop-4-ilvl-25', name = 'Bazelgeuse Drop 4', slot = 'component', rarity = 'rare', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Bazelgeuse.' WHERE id = 3354;
|
||||||
|
UPDATE items SET slug = 'odogaron-drop-1-ilvl-25', name = 'Odogaron Drop 1', slot = 'component', rarity = 'uncommon', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '1', image_url = '/equipment-placeholder.svg', description = 'A monster part from Odogaron.' WHERE id = 3361;
|
||||||
|
UPDATE items SET slug = 'odogaron-drop-2-ilvl-25', name = 'Odogaron Drop 2', slot = 'component', rarity = 'uncommon', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '2', image_url = '/equipment-placeholder.svg', description = 'A monster part from Odogaron.' WHERE id = 3362;
|
||||||
|
UPDATE items SET slug = 'odogaron-drop-3-ilvl-25', name = 'Odogaron Drop 3', slot = 'component', rarity = 'rare', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '3', image_url = '/equipment-placeholder.svg', description = 'A monster part from Odogaron.' WHERE id = 3363;
|
||||||
|
UPDATE items SET slug = 'odogaron-drop-4-ilvl-25', name = 'Odogaron Drop 4', slot = 'component', rarity = 'epic', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '4', image_url = '/equipment-placeholder.svg', description = 'A monster part from Odogaron.' WHERE id = 3364;
|
||||||
|
UPDATE items SET slug = 'bulldrome-coin-ilvl-1', name = 'Raw Bulldrome Coin', slot = 'component', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bulldrome used for item level 1 crafting.' WHERE id = 280301;
|
||||||
|
UPDATE items SET slug = 'bulldrome-coin-ilvl-5', name = 'Bulldrome Coin', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bulldrome used for item level 5 crafting.' WHERE id = 280305;
|
||||||
|
UPDATE items SET slug = 'bulldrome-coin-ilvl-10', name = 'Green Bulldrome Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bulldrome used for item level 10 crafting.' WHERE id = 280310;
|
||||||
|
UPDATE items SET slug = 'bulldrome-coin-ilvl-15', name = 'Blue Bulldrome Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bulldrome used for item level 15 crafting.' WHERE id = 280315;
|
||||||
|
UPDATE items SET slug = 'bulldrome-coin-ilvl-20', name = 'Purple Bulldrome Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bulldrome used for item level 20 crafting.' WHERE id = 280320;
|
||||||
|
UPDATE items SET slug = 'bulldrome-coin-ilvl-25', name = 'Orange Bulldrome Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bulldrome used for item level 25 crafting.' WHERE id = 280325;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-coin-ilvl-1', name = 'Raw Yian Kut-Ku Coin', slot = 'component', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 1 crafting.' WHERE id = 281201;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-coin-ilvl-5', name = 'Yian Kut-Ku Coin', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 5 crafting.' WHERE id = 281205;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-coin-ilvl-10', name = 'Green Yian Kut-Ku Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 10 crafting.' WHERE id = 281210;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-coin-ilvl-15', name = 'Blue Yian Kut-Ku Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 15 crafting.' WHERE id = 281215;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-coin-ilvl-20', name = 'Purple Yian Kut-Ku Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 20 crafting.' WHERE id = 281220;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-coin-ilvl-25', name = 'Orange Yian Kut-Ku Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 25 crafting.' WHERE id = 281225;
|
||||||
|
UPDATE items SET slug = 'rathian-coin-ilvl-1', name = 'Raw Rathian Coin', slot = 'component', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 1 crafting.' WHERE id = 282201;
|
||||||
|
UPDATE items SET slug = 'rathian-coin-ilvl-5', name = 'Rathian Coin', slot = 'component', rarity = 'common', item_level = 5, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 5 crafting.' WHERE id = 282205;
|
||||||
|
UPDATE items SET slug = 'rathian-coin-ilvl-10', name = 'Green Rathian Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 10 crafting.' WHERE id = 282210;
|
||||||
|
UPDATE items SET slug = 'rathian-coin-ilvl-15', name = 'Blue Rathian Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 15 crafting.' WHERE id = 282215;
|
||||||
|
UPDATE items SET slug = 'rathian-coin-ilvl-20', name = 'Purple Rathian Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 20 crafting.' WHERE id = 282220;
|
||||||
|
UPDATE items SET slug = 'rathian-coin-ilvl-25', name = 'Orange Rathian Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 25 crafting.' WHERE id = 282225;
|
||||||
|
UPDATE items SET slug = 'legacy-loot-encounter-3-coin-diff-1-ilvl-1', name = 'Raw Legacy Loot Encounter 3 Coin', slot = 'component', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Legacy Loot Encounter 3 used for item level 1 crafting.' WHERE id = 283001;
|
||||||
|
UPDATE items SET slug = 'legacy-loot-encounter-3-coin-diff-2-ilvl-10', name = 'Green Legacy Loot Encounter 3 Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Legacy Loot Encounter 3 used for item level 10 crafting.' WHERE id = 283002;
|
||||||
|
UPDATE items SET slug = 'legacy-loot-encounter-3-coin-diff-3-ilvl-15', name = 'Blue Legacy Loot Encounter 3 Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Legacy Loot Encounter 3 used for item level 15 crafting.' WHERE id = 283003;
|
||||||
|
UPDATE items SET slug = 'legacy-loot-encounter-3-coin-diff-4-ilvl-20', name = 'Purple Legacy Loot Encounter 3 Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Legacy Loot Encounter 3 used for item level 20 crafting.' WHERE id = 283004;
|
||||||
|
UPDATE items SET slug = 'legacy-loot-encounter-3-coin-diff-5-ilvl-25', name = 'Orange Legacy Loot Encounter 3 Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Legacy Loot Encounter 3 used for item level 25 crafting.' WHERE id = 283005;
|
||||||
|
UPDATE items SET slug = 'tigrex-raid-coin-ilvl-10', name = 'Green Tigrex Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tigrex used for item level 10 crafting.' WHERE id = 290210;
|
||||||
|
UPDATE items SET slug = 'rathalos-raid-coin-ilvl-10', name = 'Green Rathalos Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathalos used for item level 10 crafting.' WHERE id = 290510;
|
||||||
|
UPDATE items SET slug = 'gypceros-raid-coin-ilvl-10', name = 'Green Gypceros Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Gypceros used for item level 10 crafting.' WHERE id = 290810;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-coin-diff-1-ilvl-1', name = 'Raw Yian Kut-Ku Coin', slot = 'component', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 1 crafting.' WHERE id = 292001;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-coin-diff-2-ilvl-10', name = 'Green Yian Kut-Ku Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 10 crafting.' WHERE id = 292002;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-coin-diff-3-ilvl-15', name = 'Blue Yian Kut-Ku Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 15 crafting.' WHERE id = 292003;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-coin-diff-4-ilvl-20', name = 'Purple Yian Kut-Ku Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 20 crafting.' WHERE id = 292004;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-coin-diff-5-ilvl-25', name = 'Orange Yian Kut-Ku Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 25 crafting.' WHERE id = 292005;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-coin-diff-101-ilvl-10', name = 'Green Yian Kut-Ku Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 10 crafting.' WHERE id = 292101;
|
||||||
|
UPDATE items SET slug = 'rathian-coin-diff-1-ilvl-1', name = 'Raw Rathian Coin', slot = 'component', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 1 crafting.' WHERE id = 302001;
|
||||||
|
UPDATE items SET slug = 'rathian-coin-diff-2-ilvl-10', name = 'Green Rathian Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 10 crafting.' WHERE id = 302002;
|
||||||
|
UPDATE items SET slug = 'rathian-coin-diff-3-ilvl-15', name = 'Blue Rathian Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 15 crafting.' WHERE id = 302003;
|
||||||
|
UPDATE items SET slug = 'rathian-coin-diff-4-ilvl-20', name = 'Purple Rathian Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 20 crafting.' WHERE id = 302004;
|
||||||
|
UPDATE items SET slug = 'rathian-coin-diff-5-ilvl-25', name = 'Orange Rathian Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 25 crafting.' WHERE id = 302005;
|
||||||
|
UPDATE items SET slug = 'tigrex-dungeon-boss-coin-ilvl-10', name = 'Green Tigrex Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tigrex used for item level 10 crafting.' WHERE id = 310310;
|
||||||
|
UPDATE items SET slug = 'rathalos-dungeon-boss-coin-ilvl-10', name = 'Green Rathalos Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathalos used for item level 10 crafting.' WHERE id = 310610;
|
||||||
|
UPDATE items SET slug = 'gypceros-dungeon-boss-coin-ilvl-10', name = 'Green Gypceros Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Gypceros used for item level 10 crafting.' WHERE id = 310910;
|
||||||
|
UPDATE items SET slug = 'tigrex-coin-ilvl-10', name = 'Green Tigrex Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tigrex used for item level 10 crafting.' WHERE id = 320310;
|
||||||
|
UPDATE items SET slug = 'tigrex-coin-ilvl-15', name = 'Blue Tigrex Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tigrex used for item level 15 crafting.' WHERE id = 320315;
|
||||||
|
UPDATE items SET slug = 'tigrex-coin-ilvl-20', name = 'Purple Tigrex Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tigrex used for item level 20 crafting.' WHERE id = 320320;
|
||||||
|
UPDATE items SET slug = 'tigrex-coin-ilvl-25', name = 'Orange Tigrex Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tigrex used for item level 25 crafting.' WHERE id = 320325;
|
||||||
|
UPDATE items SET slug = 'azuros-dungeon-boss-coin-ilvl-15', name = 'Blue Azuros Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Azuros used for item level 15 crafting.' WHERE id = 320615;
|
||||||
|
UPDATE items SET slug = 'diablos-dungeon-boss-coin-ilvl-15', name = 'Blue Diablos Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Diablos used for item level 15 crafting.' WHERE id = 320915;
|
||||||
|
UPDATE items SET slug = 'nargacuga-raid-boss-coin-ilvl-15', name = 'Blue Nargacuga Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Nargacuga used for item level 15 crafting.' WHERE id = 330315;
|
||||||
|
UPDATE items SET slug = 'azuros-raid-boss-coin-ilvl-15', name = 'Blue Azuros Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Azuros used for item level 15 crafting.' WHERE id = 330615;
|
||||||
|
UPDATE items SET slug = 'diablos-raid-boss-coin-ilvl-15', name = 'Blue Diablos Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Diablos used for item level 15 crafting.' WHERE id = 330915;
|
||||||
|
UPDATE items SET slug = 'barroth-dungeon-boss-coin-ilvl-20', name = 'Purple Barroth Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Barroth used for item level 20 crafting.' WHERE id = 340320;
|
||||||
|
UPDATE items SET slug = 'tobi-kadachi-dungeon-boss-coin-ilvl-20', name = 'Purple Tobi Kadachi Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tobi Kadachi used for item level 20 crafting.' WHERE id = 340620;
|
||||||
|
UPDATE items SET slug = 'monoblos-dungeon-boss-coin-ilvl-20', name = 'Purple Monoblos Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Monoblos used for item level 20 crafting.' WHERE id = 340920;
|
||||||
|
UPDATE items SET slug = 'barroth-raid-boss-coin-ilvl-20', name = 'Purple Barroth Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Barroth used for item level 20 crafting.' WHERE id = 350320;
|
||||||
|
UPDATE items SET slug = 'tobi-kadachi-raid-boss-coin-ilvl-20', name = 'Purple Tobi Kadachi Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tobi Kadachi used for item level 20 crafting.' WHERE id = 350620;
|
||||||
|
UPDATE items SET slug = 'monoblos-raid-boss-coin-ilvl-20', name = 'Purple Monoblos Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Monoblos used for item level 20 crafting.' WHERE id = 350920;
|
||||||
|
UPDATE items SET slug = 'anjanath-dungeon-boss-coin-ilvl-25', name = 'Orange Anjanath Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Anjanath used for item level 25 crafting.' WHERE id = 360325;
|
||||||
|
UPDATE items SET slug = 'bazelgeuse-dungeon-boss-coin-ilvl-25', name = 'Orange Bazelgeuse Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bazelgeuse used for item level 25 crafting.' WHERE id = 360625;
|
||||||
|
UPDATE items SET slug = 'odogaron-dungeon-boss-coin-ilvl-25', name = 'Orange Odogaron Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Odogaron used for item level 25 crafting.' WHERE id = 360925;
|
||||||
|
UPDATE items SET slug = 'anjanath-raid-boss-coin-ilvl-25', name = 'Orange Anjanath Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Anjanath used for item level 25 crafting.' WHERE id = 370325;
|
||||||
|
UPDATE items SET slug = 'bazelgeuse-raid-boss-coin-ilvl-25', name = 'Orange Bazelgeuse Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bazelgeuse used for item level 25 crafting.' WHERE id = 370625;
|
||||||
|
UPDATE items SET slug = 'odogaron-raid-boss-coin-ilvl-25', name = 'Orange Odogaron Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Odogaron used for item level 25 crafting.' WHERE id = 370925;
|
||||||
|
UPDATE items SET slug = 'tigrex-raid-coin-diff-101-ilvl-10', name = 'Green Tigrex Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tigrex used for item level 10 crafting.' WHERE id = 382101;
|
||||||
|
UPDATE items SET slug = 'bulldrome-boss-coin-diff-1-ilvl-1', name = 'Raw Bulldrome Coin', slot = 'component', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bulldrome used for item level 1 crafting.' WHERE id = 383001;
|
||||||
|
UPDATE items SET slug = 'bulldrome-boss-coin-diff-2-ilvl-10', name = 'Green Bulldrome Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bulldrome used for item level 10 crafting.' WHERE id = 383002;
|
||||||
|
UPDATE items SET slug = 'bulldrome-boss-coin-diff-3-ilvl-15', name = 'Blue Bulldrome Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bulldrome used for item level 15 crafting.' WHERE id = 383003;
|
||||||
|
UPDATE items SET slug = 'bulldrome-boss-coin-diff-4-ilvl-20', name = 'Purple Bulldrome Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bulldrome used for item level 20 crafting.' WHERE id = 383004;
|
||||||
|
UPDATE items SET slug = 'bulldrome-boss-coin-diff-5-ilvl-25', name = 'Orange Bulldrome Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bulldrome used for item level 25 crafting.' WHERE id = 383005;
|
||||||
|
UPDATE items SET slug = 'high-inquisitor-vael-coin-diff-1-ilvl-1', name = 'Raw High Inquisitor Vael Coin', slot = 'component', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from High Inquisitor Vael used for item level 1 crafting.' WHERE id = 385001;
|
||||||
|
UPDATE items SET slug = 'high-inquisitor-vael-coin-diff-2-ilvl-10', name = 'Green High Inquisitor Vael Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from High Inquisitor Vael used for item level 10 crafting.' WHERE id = 385002;
|
||||||
|
UPDATE items SET slug = 'high-inquisitor-vael-coin-diff-3-ilvl-15', name = 'Blue High Inquisitor Vael Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from High Inquisitor Vael used for item level 15 crafting.' WHERE id = 385003;
|
||||||
|
UPDATE items SET slug = 'high-inquisitor-vael-coin-diff-4-ilvl-20', name = 'Purple High Inquisitor Vael Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from High Inquisitor Vael used for item level 20 crafting.' WHERE id = 385004;
|
||||||
|
UPDATE items SET slug = 'high-inquisitor-vael-coin-diff-5-ilvl-25', name = 'Orange High Inquisitor Vael Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from High Inquisitor Vael used for item level 25 crafting.' WHERE id = 385005;
|
||||||
|
UPDATE items SET slug = 'high-inquisitor-vael-coin-diff-101-ilvl-10', name = 'Green High Inquisitor Vael Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from High Inquisitor Vael used for item level 10 crafting.' WHERE id = 385101;
|
||||||
|
UPDATE items SET slug = 'the-ember-crown-coin-diff-1-ilvl-1', name = 'Raw The Ember Crown Coin', slot = 'component', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from The Ember Crown used for item level 1 crafting.' WHERE id = 388001;
|
||||||
|
UPDATE items SET slug = 'the-ember-crown-coin-diff-2-ilvl-10', name = 'Green The Ember Crown Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from The Ember Crown used for item level 10 crafting.' WHERE id = 388002;
|
||||||
|
UPDATE items SET slug = 'the-ember-crown-coin-diff-3-ilvl-15', name = 'Blue The Ember Crown Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from The Ember Crown used for item level 15 crafting.' WHERE id = 388003;
|
||||||
|
UPDATE items SET slug = 'the-ember-crown-coin-diff-4-ilvl-20', name = 'Purple The Ember Crown Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from The Ember Crown used for item level 20 crafting.' WHERE id = 388004;
|
||||||
|
UPDATE items SET slug = 'the-ember-crown-coin-diff-5-ilvl-25', name = 'Orange The Ember Crown Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from The Ember Crown used for item level 25 crafting.' WHERE id = 388005;
|
||||||
|
UPDATE items SET slug = 'the-ember-crown-coin-diff-101-ilvl-10', name = 'Green The Ember Crown Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from The Ember Crown used for item level 10 crafting.' WHERE id = 388101;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-boss-coin-diff-1-ilvl-1', name = 'Raw Yian Kut-Ku Coin', slot = 'component', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 1 crafting.' WHERE id = 483001;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-boss-coin-diff-2-ilvl-10', name = 'Green Yian Kut-Ku Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 10 crafting.' WHERE id = 483002;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-boss-coin-diff-3-ilvl-15', name = 'Blue Yian Kut-Ku Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 15 crafting.' WHERE id = 483003;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-boss-coin-diff-4-ilvl-20', name = 'Purple Yian Kut-Ku Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 20 crafting.' WHERE id = 483004;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-boss-coin-diff-5-ilvl-25', name = 'Orange Yian Kut-Ku Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 25 crafting.' WHERE id = 483005;
|
||||||
|
UPDATE items SET slug = 'yian-kut-ku-boss-coin-diff-101-ilvl-10', name = 'Green Yian Kut-Ku Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Yian Kut-Ku used for item level 10 crafting.' WHERE id = 483101;
|
||||||
|
UPDATE items SET slug = 'rathian-boss-coin-diff-1-ilvl-1', name = 'Raw Rathian Coin', slot = 'component', rarity = 'common', item_level = 1, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 1 crafting.' WHERE id = 583001;
|
||||||
|
UPDATE items SET slug = 'rathian-boss-coin-diff-2-ilvl-10', name = 'Green Rathian Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 10 crafting.' WHERE id = 583002;
|
||||||
|
UPDATE items SET slug = 'rathian-boss-coin-diff-3-ilvl-15', name = 'Blue Rathian Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 15 crafting.' WHERE id = 583003;
|
||||||
|
UPDATE items SET slug = 'rathian-boss-coin-diff-4-ilvl-20', name = 'Purple Rathian Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 20 crafting.' WHERE id = 583004;
|
||||||
|
UPDATE items SET slug = 'rathian-boss-coin-diff-5-ilvl-25', name = 'Orange Rathian Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathian used for item level 25 crafting.' WHERE id = 583005;
|
||||||
|
UPDATE items SET slug = 'tigrex-boss-coin-diff-2-ilvl-10', name = 'Green Tigrex Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tigrex used for item level 10 crafting.' WHERE id = 683002;
|
||||||
|
UPDATE items SET slug = 'tigrex-boss-coin-diff-3-ilvl-15', name = 'Blue Tigrex Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tigrex used for item level 15 crafting.' WHERE id = 683003;
|
||||||
|
UPDATE items SET slug = 'tigrex-boss-coin-diff-4-ilvl-20', name = 'Purple Tigrex Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tigrex used for item level 20 crafting.' WHERE id = 683004;
|
||||||
|
UPDATE items SET slug = 'tigrex-boss-coin-diff-5-ilvl-25', name = 'Orange Tigrex Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tigrex used for item level 25 crafting.' WHERE id = 683005;
|
||||||
|
UPDATE items SET slug = 'rathalos-boss-coin-diff-2-ilvl-10', name = 'Green Rathalos Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathalos used for item level 10 crafting.' WHERE id = 783002;
|
||||||
|
UPDATE items SET slug = 'rathalos-boss-coin-diff-3-ilvl-15', name = 'Blue Rathalos Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathalos used for item level 15 crafting.' WHERE id = 783003;
|
||||||
|
UPDATE items SET slug = 'rathalos-boss-coin-diff-4-ilvl-20', name = 'Purple Rathalos Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathalos used for item level 20 crafting.' WHERE id = 783004;
|
||||||
|
UPDATE items SET slug = 'rathalos-boss-coin-diff-5-ilvl-25', name = 'Orange Rathalos Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Rathalos used for item level 25 crafting.' WHERE id = 783005;
|
||||||
|
UPDATE items SET slug = 'nargacuga-raid-boss-coin-diff-103-ilvl-15', name = 'Blue Apex Nargacuga Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Apex Nargacuga used for item level 15 crafting.' WHERE id = 783103;
|
||||||
|
UPDATE items SET slug = 'azuros-raid-boss-coin-diff-103-ilvl-15', name = 'Blue Apex Azuros Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Apex Azuros used for item level 15 crafting.' WHERE id = 786103;
|
||||||
|
UPDATE items SET slug = 'diablos-raid-boss-coin-diff-103-ilvl-15', name = 'Blue Apex Diablos Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Apex Diablos used for item level 15 crafting.' WHERE id = 789103;
|
||||||
|
UPDATE items SET slug = 'gypceros-boss-coin-diff-2-ilvl-10', name = 'Green Gypceros Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Gypceros used for item level 10 crafting.' WHERE id = 883002;
|
||||||
|
UPDATE items SET slug = 'gypceros-boss-coin-diff-3-ilvl-15', name = 'Blue Gypceros Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Gypceros used for item level 15 crafting.' WHERE id = 883003;
|
||||||
|
UPDATE items SET slug = 'gypceros-boss-coin-diff-4-ilvl-20', name = 'Purple Gypceros Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Gypceros used for item level 20 crafting.' WHERE id = 883004;
|
||||||
|
UPDATE items SET slug = 'gypceros-boss-coin-diff-5-ilvl-25', name = 'Orange Gypceros Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Gypceros used for item level 25 crafting.' WHERE id = 883005;
|
||||||
|
UPDATE items SET slug = 'nargacuga-boss-coin-diff-3-ilvl-15', name = 'Blue Nargacuga Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Nargacuga used for item level 15 crafting.' WHERE id = 983003;
|
||||||
|
UPDATE items SET slug = 'nargacuga-boss-coin-diff-4-ilvl-20', name = 'Purple Nargacuga Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Nargacuga used for item level 20 crafting.' WHERE id = 983004;
|
||||||
|
UPDATE items SET slug = 'nargacuga-boss-coin-diff-5-ilvl-25', name = 'Orange Nargacuga Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Nargacuga used for item level 25 crafting.' WHERE id = 983005;
|
||||||
|
UPDATE items SET slug = 'barroth-raid-boss-coin-diff-104-ilvl-20', name = 'Purple Apex Barroth Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Apex Barroth used for item level 20 crafting.' WHERE id = 983104;
|
||||||
|
UPDATE items SET slug = 'tobi-kadachi-raid-boss-coin-diff-104-ilvl-20', name = 'Purple Apex Tobi Kadachi Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Apex Tobi Kadachi used for item level 20 crafting.' WHERE id = 986104;
|
||||||
|
UPDATE items SET slug = 'monoblos-raid-boss-coin-diff-104-ilvl-20', name = 'Purple Apex Monoblos Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Apex Monoblos used for item level 20 crafting.' WHERE id = 989104;
|
||||||
|
UPDATE items SET slug = 'azuros-boss-coin-diff-3-ilvl-15', name = 'Blue Azuros Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Azuros used for item level 15 crafting.' WHERE id = 1083003;
|
||||||
|
UPDATE items SET slug = 'azuros-boss-coin-diff-4-ilvl-20', name = 'Purple Azuros Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Azuros used for item level 20 crafting.' WHERE id = 1083004;
|
||||||
|
UPDATE items SET slug = 'azuros-boss-coin-diff-5-ilvl-25', name = 'Orange Azuros Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Azuros used for item level 25 crafting.' WHERE id = 1083005;
|
||||||
|
UPDATE items SET slug = 'diablos-boss-coin-diff-3-ilvl-15', name = 'Blue Diablos Coin', slot = 'component', rarity = 'rare', item_level = 15, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Diablos used for item level 15 crafting.' WHERE id = 1183003;
|
||||||
|
UPDATE items SET slug = 'diablos-boss-coin-diff-4-ilvl-20', name = 'Purple Diablos Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Diablos used for item level 20 crafting.' WHERE id = 1183004;
|
||||||
|
UPDATE items SET slug = 'diablos-boss-coin-diff-5-ilvl-25', name = 'Orange Diablos Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Diablos used for item level 25 crafting.' WHERE id = 1183005;
|
||||||
|
UPDATE items SET slug = 'anjanath-raid-boss-coin-diff-105-ilvl-25', name = 'Orange Apex Anjanath Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Apex Anjanath used for item level 25 crafting.' WHERE id = 1183105;
|
||||||
|
UPDATE items SET slug = 'bazelgeuse-raid-boss-coin-diff-105-ilvl-25', name = 'Orange Apex Bazelgeuse Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Apex Bazelgeuse used for item level 25 crafting.' WHERE id = 1186105;
|
||||||
|
UPDATE items SET slug = 'odogaron-raid-boss-coin-diff-105-ilvl-25', name = 'Orange Apex Odogaron Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Apex Odogaron used for item level 25 crafting.' WHERE id = 1189105;
|
||||||
|
UPDATE items SET slug = 'barroth-boss-coin-diff-4-ilvl-20', name = 'Purple Barroth Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Barroth used for item level 20 crafting.' WHERE id = 1283004;
|
||||||
|
UPDATE items SET slug = 'barroth-boss-coin-diff-5-ilvl-25', name = 'Orange Barroth Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Barroth used for item level 25 crafting.' WHERE id = 1283005;
|
||||||
|
UPDATE items SET slug = 'tobi-kadachi-boss-coin-diff-4-ilvl-20', name = 'Purple Tobi Kadachi Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tobi Kadachi used for item level 20 crafting.' WHERE id = 1383004;
|
||||||
|
UPDATE items SET slug = 'tobi-kadachi-boss-coin-diff-5-ilvl-25', name = 'Orange Tobi Kadachi Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Tobi Kadachi used for item level 25 crafting.' WHERE id = 1383005;
|
||||||
|
UPDATE items SET slug = 'monoblos-boss-coin-diff-4-ilvl-20', name = 'Purple Monoblos Coin', slot = 'component', rarity = 'epic', item_level = 20, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Monoblos used for item level 20 crafting.' WHERE id = 1483004;
|
||||||
|
UPDATE items SET slug = 'monoblos-boss-coin-diff-5-ilvl-25', name = 'Orange Monoblos Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Monoblos used for item level 25 crafting.' WHERE id = 1483005;
|
||||||
|
UPDATE items SET slug = 'anjanath-boss-coin-diff-5-ilvl-25', name = 'Orange Anjanath Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Anjanath used for item level 25 crafting.' WHERE id = 1583005;
|
||||||
|
UPDATE items SET slug = 'bazelgeuse-boss-coin-diff-5-ilvl-25', name = 'Orange Bazelgeuse Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Bazelgeuse used for item level 25 crafting.' WHERE id = 1683005;
|
||||||
|
UPDATE items SET slug = 'odogaron-boss-coin-diff-5-ilvl-25', name = 'Orange Odogaron Coin', slot = 'component', rarity = 'legendary', item_level = 25, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Odogaron used for item level 25 crafting.' WHERE id = 1783005;
|
||||||
|
UPDATE items SET slug = 'tigrex-raid-boss-coin-diff-101-ilvl-10', name = 'Green Apex Tigrex Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Apex Tigrex used for item level 10 crafting.' WHERE id = 2283101;
|
||||||
|
UPDATE items SET slug = 'rathalos-raid-boss-coin-diff-101-ilvl-10', name = 'Green Apex Rathalos Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Apex Rathalos used for item level 10 crafting.' WHERE id = 2286101;
|
||||||
|
UPDATE items SET slug = 'gypceros-raid-boss-coin-diff-101-ilvl-10', name = 'Green Apex Gypceros Coin', slot = 'component', rarity = 'uncommon', item_level = 10, healing_power = 0, max_resource_bonus = 0, glyph = '$', image_url = '/equipment-placeholder.svg', description = 'A boss coin from Apex Gypceros used for item level 10 crafting.' WHERE id = 2289101;
|
||||||
|
|
||||||
|
DELETE FROM encounter_loot;
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (103, 383001, 1, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (103, 383002, 2, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (103, 383003, 3, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (103, 383004, 4, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (103, 383005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (203, 483001, 1, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (203, 483002, 2, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (203, 483003, 3, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (203, 483004, 4, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (203, 483005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (303, 583001, 1, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (303, 583002, 2, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (303, 583003, 3, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (303, 583004, 4, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (303, 583005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (403, 683002, 2, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (403, 683003, 3, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (403, 683004, 4, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (403, 683005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (503, 783002, 2, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (503, 783003, 3, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (503, 783004, 4, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (503, 783005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (603, 883002, 2, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (603, 883003, 3, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (603, 883004, 4, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (603, 883005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (703, 983003, 3, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (703, 983004, 4, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (703, 983005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (803, 1083003, 3, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (803, 1083004, 4, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (803, 1083005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (903, 1183003, 3, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (903, 1183004, 4, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (903, 1183005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1003, 1283004, 4, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1003, 1283005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1103, 1383004, 4, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1103, 1383005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1203, 1483004, 4, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1203, 1483005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1303, 1583005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1403, 1683005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (1503, 1783005, 5, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (2003, 2283101, 101, 100, 1);
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2103, id, 101, 100, 1 FROM items WHERE slug = 'rathalos-raid-boss-coin-diff-101-ilvl-10';
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2203, id, 101, 100, 1 FROM items WHERE slug = 'gypceros-raid-boss-coin-diff-101-ilvl-10';
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2303, id, 103, 100, 1 FROM items WHERE slug = 'nargacuga-raid-boss-coin-diff-103-ilvl-15';
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2403, id, 103, 100, 1 FROM items WHERE slug = 'azuros-raid-boss-coin-diff-103-ilvl-15';
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2503, id, 103, 100, 1 FROM items WHERE slug = 'diablos-raid-boss-coin-diff-103-ilvl-15';
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2603, id, 104, 100, 1 FROM items WHERE slug = 'barroth-raid-boss-coin-diff-104-ilvl-20';
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2703, id, 104, 100, 1 FROM items WHERE slug = 'tobi-kadachi-raid-boss-coin-diff-104-ilvl-20';
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2803, id, 104, 100, 1 FROM items WHERE slug = 'monoblos-raid-boss-coin-diff-104-ilvl-20';
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 2903, id, 105, 100, 1 FROM items WHERE slug = 'anjanath-raid-boss-coin-diff-105-ilvl-25';
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 3003, id, 105, 100, 1 FROM items WHERE slug = 'bazelgeuse-raid-boss-coin-diff-105-ilvl-25';
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) SELECT 3103, id, 105, 100, 1 FROM items WHERE slug = 'odogaron-raid-boss-coin-diff-105-ilvl-25';
|
||||||
|
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 1, source_encounter_id = 103 WHERE id = 1001;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 1, source_encounter_id = 103 WHERE id = 1002;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 1, source_encounter_id = 103 WHERE id = 1003;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 2, source_encounter_id = 203 WHERE id = 1004;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 2, source_encounter_id = 203 WHERE id = 1005;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 2, source_encounter_id = 203 WHERE id = 1006;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 3, source_encounter_id = 303 WHERE id = 1007;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 3, source_encounter_id = 303 WHERE id = 1008;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 1, source_dungeon_id = 3, source_encounter_id = 303 WHERE id = 1009;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 4, source_encounter_id = 403 WHERE id = 1101;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 4, source_encounter_id = 403 WHERE id = 1102;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 4, source_encounter_id = 403 WHERE id = 1103;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 5, source_encounter_id = 503 WHERE id = 1104;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 5, source_encounter_id = 503 WHERE id = 1105;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 5, source_encounter_id = 503 WHERE id = 1106;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 6, source_encounter_id = 603 WHERE id = 1107;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 6, source_encounter_id = 603 WHERE id = 1108;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 6, source_encounter_id = 603 WHERE id = 1109;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 7, source_encounter_id = 703 WHERE id = 1201;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 7, source_encounter_id = 703 WHERE id = 1202;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 7, source_encounter_id = 703 WHERE id = 1203;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 8, source_encounter_id = 803 WHERE id = 1204;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 8, source_encounter_id = 803 WHERE id = 1205;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 8, source_encounter_id = 803 WHERE id = 1206;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 9, source_encounter_id = 903 WHERE id = 1207;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 9, source_encounter_id = 903 WHERE id = 1208;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 2, source_dungeon_id = 9, source_encounter_id = 903 WHERE id = 1209;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 4, source_dungeon_id = 10, source_encounter_id = 1003 WHERE id = 1301;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 4, source_dungeon_id = 10, source_encounter_id = 1003 WHERE id = 1302;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 4, source_dungeon_id = 10, source_encounter_id = 1003 WHERE id = 1303;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 4, source_dungeon_id = 11, source_encounter_id = 1103 WHERE id = 1304;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 4, source_dungeon_id = 11, source_encounter_id = 1103 WHERE id = 1305;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 4, source_dungeon_id = 11, source_encounter_id = 1103 WHERE id = 1306;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 4, source_dungeon_id = 12, source_encounter_id = 1203 WHERE id = 1307;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 4, source_dungeon_id = 12, source_encounter_id = 1203 WHERE id = 1308;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 4, source_dungeon_id = 12, source_encounter_id = 1203 WHERE id = 1309;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 5, source_dungeon_id = 13, source_encounter_id = 1303 WHERE id = 1401;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 5, source_dungeon_id = 13, source_encounter_id = 1303 WHERE id = 1402;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 5, source_dungeon_id = 13, source_encounter_id = 1303 WHERE id = 1403;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 5, source_dungeon_id = 14, source_encounter_id = 1403 WHERE id = 1404;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 5, source_dungeon_id = 14, source_encounter_id = 1403 WHERE id = 1405;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 5, source_dungeon_id = 14, source_encounter_id = 1403 WHERE id = 1406;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 5, source_dungeon_id = 15, source_encounter_id = 1503 WHERE id = 1407;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 5, source_dungeon_id = 15, source_encounter_id = 1503 WHERE id = 1408;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 5, source_dungeon_id = 15, source_encounter_id = 1503 WHERE id = 1409;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 101, source_dungeon_id = 20, source_encounter_id = 2003 WHERE id = 2001;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 101, source_dungeon_id = 20, source_encounter_id = 2003 WHERE id = 2002;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 101, source_dungeon_id = 20, source_encounter_id = 2003 WHERE id = 2003;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 101, source_dungeon_id = 21, source_encounter_id = 2103 WHERE id = 2004;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 101, source_dungeon_id = 21, source_encounter_id = 2103 WHERE id = 2005;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 101, source_dungeon_id = 21, source_encounter_id = 2103 WHERE id = 2006;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 101, source_dungeon_id = 22, source_encounter_id = 2203 WHERE id = 2007;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 101, source_dungeon_id = 22, source_encounter_id = 2203 WHERE id = 2008;
|
||||||
|
UPDATE crafting_recipes SET difficulty_id = 101, source_dungeon_id = 22, source_encounter_id = 2203 WHERE id = 2009;
|
||||||
|
|
||||||
|
DELETE FROM crafting_recipe_components;
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1001, 383001, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1002, 383001, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1003, 383001, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1004, 483001, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1005, 483001, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1006, 483001, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1007, 583001, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1008, 583001, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1009, 583001, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1101, 383002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1101, 683002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1102, 383002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1102, 683002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1103, 383002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1103, 683002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1104, 483002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1104, 783002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1105, 483002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1105, 783002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1106, 483002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1106, 783002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1107, 583002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1107, 883002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1108, 583002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1108, 883002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1109, 583002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1109, 883002, 5);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1201, 683003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1201, 983003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1202, 683003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1202, 983003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1203, 683003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1203, 983003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1204, 783003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1204, 1083003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1205, 783003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1205, 1083003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1206, 783003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1206, 1083003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1207, 883003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1207, 1183003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1208, 883003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1208, 1183003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1209, 883003, 7);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1209, 1183003, 8);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1301, 983004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1301, 1283004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1302, 983004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1302, 1283004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1303, 983004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1303, 1283004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1304, 1083004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1304, 1383004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1305, 1083004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1305, 1383004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1306, 1083004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1306, 1383004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1307, 1183004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1307, 1483004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1308, 1183004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1308, 1483004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1309, 1183004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1309, 1483004, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1401, 1283005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1401, 1583005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1402, 1283005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1402, 1583005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1403, 1283005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1403, 1583005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1404, 1383005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1404, 1683005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1405, 1383005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1405, 1683005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1406, 1383005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1406, 1683005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1407, 1483005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1407, 1783005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1408, 1483005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1408, 1783005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1409, 1483005, 12);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (1409, 1783005, 13);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2001, 2283101, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2002, 2283101, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (2003, 2283101, 10);
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) SELECT 2004, id, 10 FROM items WHERE slug = 'rathalos-raid-boss-coin-diff-101-ilvl-10';
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) SELECT 2005, id, 10 FROM items WHERE slug = 'rathalos-raid-boss-coin-diff-101-ilvl-10';
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) SELECT 2006, id, 10 FROM items WHERE slug = 'rathalos-raid-boss-coin-diff-101-ilvl-10';
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) SELECT 2007, id, 10 FROM items WHERE slug = 'gypceros-raid-boss-coin-diff-101-ilvl-10';
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) SELECT 2008, id, 10 FROM items WHERE slug = 'gypceros-raid-boss-coin-diff-101-ilvl-10';
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) SELECT 2009, id, 10 FROM items WHERE slug = 'gypceros-raid-boss-coin-diff-101-ilvl-10';
|
||||||
|
|
||||||
|
DELETE FROM gear_upgrade_paths;
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (1, 201);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (2, 202);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (3, 203);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (4, 204);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (5, 205);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (6, 206);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (7, 207);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (8, 208);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (9, 209);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (100, 3);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (101, 5);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (102, 2);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (103, 6);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (104, 4);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (105, 1);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (106, 7);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (107, 3);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (108, 8);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (109, 9);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (201, 301);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (202, 302);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (203, 303);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (204, 304);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (205, 305);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (206, 306);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (207, 307);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (208, 308);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (209, 309);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (301, 401);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (302, 402);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (303, 403);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (304, 404);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (305, 405);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (306, 406);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (307, 407);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (308, 408);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (309, 409);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (401, 501);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (402, 502);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (403, 503);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (404, 504);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (405, 505);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (406, 506);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (407, 507);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (408, 508);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (409, 509);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (710, 301);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (711, 302);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (712, 303);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (713, 304);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (714, 305);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (715, 306);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (716, 307);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (717, 308);
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (718, 309);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS dungeons (
|
|||||||
party_size INTEGER NOT NULL DEFAULT 6,
|
party_size INTEGER NOT NULL DEFAULT 6,
|
||||||
completion_item_level INTEGER,
|
completion_item_level INTEGER,
|
||||||
experience_reward INTEGER NOT NULL DEFAULT 100,
|
experience_reward INTEGER NOT NULL DEFAULT 100,
|
||||||
|
image_url TEXT NOT NULL DEFAULT '/boss-placeholder.svg',
|
||||||
description TEXT NOT NULL
|
description TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -132,6 +133,12 @@ CREATE TABLE IF NOT EXISTS crafting_recipe_components (
|
|||||||
PRIMARY KEY (recipe_id, item_id)
|
PRIMARY KEY (recipe_id, item_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS gear_upgrade_paths (
|
||||||
|
from_item_id INTEGER PRIMARY KEY REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
to_item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
CHECK (from_item_id <> to_item_id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS item_sets (
|
CREATE TABLE IF NOT EXISTS item_sets (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Gearing System
|
||||||
|
|
||||||
|
## Current Rule
|
||||||
|
|
||||||
|
The game uses fewer playable content tiers and more gear upgrade steps.
|
||||||
|
|
||||||
|
Content tiers:
|
||||||
|
|
||||||
|
| Content Tier | Unlock Level | Purpose |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| iLvl 1 | 1 | First real gear set |
|
||||||
|
| iLvl 10 | 10 | Midgame jump |
|
||||||
|
| iLvl 20 | 20 | Endgame jump |
|
||||||
|
| iLvl 25 | 25 | Hard endgame |
|
||||||
|
|
||||||
|
Gear tiers:
|
||||||
|
|
||||||
|
| Gear Tier | How It Is Used |
|
||||||
|
| ---: | --- |
|
||||||
|
| 1 | Crafted from iLvl 1 content |
|
||||||
|
| 5 | Upgrade tier from iLvl 1 content coins |
|
||||||
|
| 10 | Crafted/upgraded from iLvl 10 content coins |
|
||||||
|
| 15 | Gear-only upgrade tier from iLvl 10 content coins |
|
||||||
|
| 20 | Crafted/upgraded from iLvl 20 content coins |
|
||||||
|
| 25 | Crafted/upgraded from iLvl 25 content coins |
|
||||||
|
|
||||||
|
This keeps the dungeon/raid picker simple while still giving players steady gear goals.
|
||||||
|
|
||||||
|
## New Characters
|
||||||
|
|
||||||
|
New characters start with no gear equipped.
|
||||||
|
|
||||||
|
The first goal is to clear iLvl 1 content, earn raw boss coins, and craft the first iLvl 1 set. Starter gear exists as craftable gear, not automatic inventory.
|
||||||
|
|
||||||
|
## Coin Tiers
|
||||||
|
|
||||||
|
Coins are component items. Each coin is tied to a boss and a content tier.
|
||||||
|
|
||||||
|
| Content Tier | Coin Prefix | Rarity Key | Example |
|
||||||
|
| ---: | --- | --- | --- |
|
||||||
|
| 1 | Raw | common | Raw Bulldrome Coin |
|
||||||
|
| 10 | Green | uncommon | Green Bulldrome Coin |
|
||||||
|
| 20 | Purple | epic | Purple Bulldrome Coin |
|
||||||
|
| 25 | Orange | legendary | Orange Bulldrome Coin |
|
||||||
|
|
||||||
|
iLvl 5 and iLvl 15 gear do not have their own playable content tier. They use coins from the previous playable tier:
|
||||||
|
|
||||||
|
| Gear Tier | Coin Tier Used |
|
||||||
|
| ---: | ---: |
|
||||||
|
| 1 | 1 |
|
||||||
|
| 5 | 1 |
|
||||||
|
| 10 | 10 |
|
||||||
|
| 15 | 10 |
|
||||||
|
| 20 | 20 |
|
||||||
|
| 25 | 25 |
|
||||||
|
|
||||||
|
## Boss Loot
|
||||||
|
|
||||||
|
Each boss drops one boss coin for the selected content tier.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Bulldrome at iLvl 1 drops Raw Bulldrome Coins.
|
||||||
|
- Tigrex at iLvl 10 drops Green Tigrex Coins.
|
||||||
|
- Barroth at iLvl 20 drops Purple Barroth Coins.
|
||||||
|
- Anjanath at iLvl 25 drops Orange Anjanath Coins.
|
||||||
|
|
||||||
|
## Crafting And Upgrades
|
||||||
|
|
||||||
|
The first gear item in a boss/item line can be crafted directly. Higher versions should be reached through Upgrade.
|
||||||
|
|
||||||
|
Current rule:
|
||||||
|
|
||||||
|
- Craft iLvl 1 boss gear directly.
|
||||||
|
- Upgrade iLvl 1 -> 5 with iLvl 1 coins.
|
||||||
|
- Craft or upgrade iLvl 10 gear from iLvl 10 content.
|
||||||
|
- Upgrade iLvl 10 -> 15 with iLvl 10 coins.
|
||||||
|
- Craft or upgrade iLvl 20 gear from iLvl 20 content.
|
||||||
|
- Craft or upgrade iLvl 25 gear from iLvl 25 content.
|
||||||
|
|
||||||
|
Upgrade consumes the old item and awards the upgraded item. This avoids duplicate clutter and keeps item identity clear.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
| Upgrade | Cost Source |
|
||||||
|
| --- | --- |
|
||||||
|
| Raw Bulldrome Helmet iLvl 1 -> Honed Bulldrome Helmet iLvl 5 | Raw Bulldrome Coins |
|
||||||
|
| Green Tigrex Helmet iLvl 10 -> Blue Tigrex Helmet iLvl 15 | Green Tigrex Coins |
|
||||||
|
| Purple Bulldrome Helmet iLvl 20 -> Orange Bulldrome Helmet iLvl 25 | Orange Bulldrome Coins |
|
||||||
|
|
||||||
|
## UI Behavior
|
||||||
|
|
||||||
|
The dungeon and raid picker only shows playable content tiers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
iLvl 1 -> iLvl 10 -> iLvl 20 -> iLvl 25
|
||||||
|
```
|
||||||
|
|
||||||
|
The equipment screen still shows gear recipes for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
iLvl 1 -> iLvl 5 -> iLvl 10 -> iLvl 15 -> iLvl 20 -> iLvl 25
|
||||||
|
```
|
||||||
|
|
||||||
|
Direct crafting is blocked for recipes that have a lower item-level version in the same boss/item line. Use the selected item's Upgrade button for those.
|
||||||
|
|
||||||
|
## Roguelike Loot
|
||||||
|
|
||||||
|
Roguelike gear should follow the same tier brackets.
|
||||||
|
|
||||||
|
Recommended mapping:
|
||||||
|
|
||||||
|
| Stage Band | Coin Tier |
|
||||||
|
| --- | ---: |
|
||||||
|
| 1-4 | 1 |
|
||||||
|
| 5-9 | 10 |
|
||||||
|
| 10-14 | 10 |
|
||||||
|
| 15-19 | 20 |
|
||||||
|
| 20+ | 25 |
|
||||||
|
|
||||||
|
Boss-based coins are still preferred. A roguelike boss should award coins for its actual boss template when possible.
|
||||||
|
|
||||||
|
## Data Notes
|
||||||
|
|
||||||
|
Authoritative gearing data lives in SQLite seed data:
|
||||||
|
|
||||||
|
- `db/seed.sql`
|
||||||
|
- `src/offline-starter-profile.json`
|
||||||
|
|
||||||
|
Run this after changing seed data:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run db:init
|
||||||
|
npm run offline:export
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run build` already runs `offline:export`, so a production build also refreshes the offline starter profile.
|
||||||
|
|
||||||
|
TrueNAS keeps its own persistent `data/game.db`. Pushing code does not merge or replace that database. The TrueNAS app applies seed/schema changes when the container starts and runs `npm run db:init`.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Push Updates
|
||||||
|
|
||||||
|
Use this when pushing code from the Mac to `git.whoagland.com`, then updating the TrueNAS-hosted server.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Git deploys code only.
|
||||||
|
- TrueNAS save data lives in `/mnt/usbssds/apps/iwanttoheal/data/game.db`.
|
||||||
|
- Do not commit, copy, or replace `data/game.db`.
|
||||||
|
- Do not run character reset commands unless you intentionally want a wipe.
|
||||||
|
- Restarting the TrueNAS app runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start`.
|
||||||
|
|
||||||
|
## Step 1: Build Web Locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This validates TypeScript, builds the browser app, and refreshes `src/offline-starter-profile.json`.
|
||||||
|
|
||||||
|
## Step 2: Optional Android APK
|
||||||
|
|
||||||
|
Only run this when building a new APK.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
|
||||||
|
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
VERSION="1.0.27"
|
||||||
|
|
||||||
|
CURRENT_CODE=$(grep -E 'versionCode [0-9]+' android/app/build.gradle | awk '{print $2}')
|
||||||
|
NEXT_CODE=$((CURRENT_CODE + 1))
|
||||||
|
|
||||||
|
perl -0pi -e "s/versionCode\s+\d+/versionCode $NEXT_CODE/" android/app/build.gradle
|
||||||
|
perl -0pi -e "s/versionName\s+\"[^\"]+\"/versionName \"$VERSION\"/" android/app/build.gradle
|
||||||
|
|
||||||
|
VITE_API_BASE_URL="https://iwanttoheal.phenomrom.com" npm run android:sync
|
||||||
|
|
||||||
|
cd android
|
||||||
|
./gradlew clean assembleDebug
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
cp android/app/build/outputs/apk/debug/app-debug.apk "IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
ls -lh "IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Commit And Push Code
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
|
||||||
|
git add .
|
||||||
|
git commit -m "Update game 1.0.27"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Check before committing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: source/doc/build-output changes only. Do not stage `data/game.db`.
|
||||||
|
|
||||||
|
## Step 4: Optional Gitea Release For APK
|
||||||
|
|
||||||
|
Only run this when Step 2 created a new APK.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
|
||||||
|
export GITEA_URL="https://git.whoagland.com"
|
||||||
|
export GITEA_OWNER="phenom"
|
||||||
|
export GITEA_REPO="i-want-to-heal"
|
||||||
|
export GITEA_TOKEN="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
|
||||||
|
```
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
|
||||||
|
<text x="1075" y="88" fill="#e5b95f" font-family="monospace" font-size="18">B Back</text>
|
||||||
|
|
||||||
|
<text x="78" y="158" fill="#e5b95f" font-family="monospace" font-size="18">Pick Dungeon</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="178" width="335" height="128" fill="#24262f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<rect x="94" y="194" width="72" height="72" fill="#481e29" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="116" y="241" fill="#ef6574" font-family="monospace" font-size="28" font-weight="700">AH</text>
|
||||||
|
<text x="184" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="184" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 1 • 6 Players</text>
|
||||||
|
<text x="184" y="276" fill="#e5b95f" font-family="monospace" font-size="16">Selected</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="436" y="178" width="335" height="128" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="452" y="194" width="72" height="72" fill="#2a263f" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="474" y="241" fill="#8ca9ff" font-family="monospace" font-size="28" font-weight="700">SC</text>
|
||||||
|
<text x="542" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt</text>
|
||||||
|
<text x="542" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 5 • 6 Players</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="794" y="178" width="335" height="128" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="810" y="194" width="72" height="72" fill="#223226" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="832" y="241" fill="#70d990" font-family="monospace" font-size="28" font-weight="700">GM</text>
|
||||||
|
<text x="900" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Grove Maw</text>
|
||||||
|
<text x="900" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 10 • 6 Players</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="356" fill="#e5b95f" font-family="monospace" font-size="18">Pick Part</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="376" width="282" height="82" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="112" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 1</text>
|
||||||
|
<text x="250" y="426" fill="#8f90a0" font-family="monospace" font-size="16">3 fights</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="382" y="376" width="282" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="416" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 2</text>
|
||||||
|
<text x="554" y="426" fill="#8f90a0" font-family="monospace" font-size="16">Locked</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="686" y="376" width="282" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="720" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 3</text>
|
||||||
|
<text x="858" y="426" fill="#8f90a0" font-family="monospace" font-size="16">Locked</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="508" fill="#e5b95f" font-family="monospace" font-size="18">Pick Difficulty</text>
|
||||||
|
<rect x="78" y="528" width="240" height="64" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="116" y="568" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Normal</text>
|
||||||
|
<rect x="342" y="528" width="240" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="380" y="568" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Heroic</text>
|
||||||
|
<rect x="606" y="528" width="240" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="644" y="568" fill="#777988" font-family="monospace" font-size="20" font-weight="700">Mythic L10</text>
|
||||||
|
|
||||||
|
<rect x="914" y="508" width="238" height="86" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="982" y="559" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Start</text>
|
||||||
|
<text x="78" y="638" fill="#aaa9b7" font-family="monospace" font-size="17">Idea A: All choices are button grids. D-pad works everywhere. No native dropdown.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
|
||||||
|
<text x="1014" y="88" fill="#e5b95f" font-family="monospace" font-size="18">LB/RB Change</text>
|
||||||
|
|
||||||
|
<rect x="78" y="150" width="760" height="398" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="112" y="184" width="164" height="164" fill="#481e29" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="151" y="280" fill="#ef6574" font-family="monospace" font-size="48" font-weight="700">AH</text>
|
||||||
|
<text x="306" y="202" fill="#8f90a0" font-family="monospace" font-size="18">CURRENT DUNGEON</text>
|
||||||
|
<text x="306" y="248" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="306" y="286" fill="#aaa9b7" font-family="monospace" font-size="18">Guide a six-player party through burning halls.</text>
|
||||||
|
<text x="306" y="324" fill="#e5b95f" font-family="monospace" font-size="18">Level 1 • 6 Players • 100 XP</text>
|
||||||
|
<rect x="112" y="384" width="690" height="104" fill="#15161c" stroke="#33343d" stroke-width="3"/>
|
||||||
|
<text x="138" y="425" fill="#f2f0dc" font-family="monospace" font-size="19">Ashen Halls</text>
|
||||||
|
<text x="356" y="425" fill="#8f90a0" font-family="monospace" font-size="19">Sunken Crypt</text>
|
||||||
|
<text x="608" y="425" fill="#8f90a0" font-family="monospace" font-size="19">Grove Maw</text>
|
||||||
|
<rect x="128" y="448" width="146" height="8" fill="#e5b95f"/>
|
||||||
|
|
||||||
|
<rect x="874" y="150" width="278" height="398" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="906" y="194" fill="#e5b95f" font-family="monospace" font-size="18">Setup</text>
|
||||||
|
<text x="906" y="232" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Part</text>
|
||||||
|
<rect x="906" y="252" width="68" height="54" fill="#29291f" stroke="#e5b95f" stroke-width="4"/>
|
||||||
|
<text x="932" y="286" fill="#f2f0dc" font-family="monospace" font-size="20">1</text>
|
||||||
|
<rect x="990" y="252" width="68" height="54" fill="#20222a" stroke="#41404a" stroke-width="3"/>
|
||||||
|
<text x="1016" y="286" fill="#8f90a0" font-family="monospace" font-size="20">2</text>
|
||||||
|
<rect x="1074" y="252" width="68" height="54" fill="#20222a" stroke="#41404a" stroke-width="3"/>
|
||||||
|
<text x="1100" y="286" fill="#8f90a0" font-family="monospace" font-size="20">3</text>
|
||||||
|
|
||||||
|
<text x="906" y="350" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Difficulty</text>
|
||||||
|
<rect x="906" y="372" width="236" height="52" fill="#29291f" stroke="#e5b95f" stroke-width="4"/>
|
||||||
|
<text x="938" y="405" fill="#f2f0dc" font-family="monospace" font-size="18">Normal</text>
|
||||||
|
<rect x="906" y="436" width="236" height="52" fill="#20222a" stroke="#41404a" stroke-width="3"/>
|
||||||
|
<text x="938" y="469" fill="#aaa9b7" font-family="monospace" font-size="18">Heroic</text>
|
||||||
|
|
||||||
|
<rect x="78" y="580" width="238" height="70" fill="#15161c" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="120" y="624" fill="#e5b95f" font-family="monospace" font-size="22">Prev</text>
|
||||||
|
<rect x="342" y="580" width="238" height="70" fill="#15161c" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="392" y="624" fill="#e5b95f" font-family="monospace" font-size="22">Next</text>
|
||||||
|
<rect x="874" y="580" width="278" height="70" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="963" y="624" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Start</text>
|
||||||
|
<text x="78" y="684" fill="#aaa9b7" font-family="monospace" font-size="17">Idea B: One big focused dungeon. Shoulder buttons or side buttons cycle dungeon.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Mission Board</text>
|
||||||
|
<text x="1046" y="88" fill="#e5b95f" font-family="monospace" font-size="18">A Start</text>
|
||||||
|
|
||||||
|
<rect x="78" y="150" width="500" height="474" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="112" y="194" fill="#e5b95f" font-family="monospace" font-size="18">Available Runs</text>
|
||||||
|
<g>
|
||||||
|
<rect x="112" y="222" width="432" height="82" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="136" y="255" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 1</text>
|
||||||
|
<text x="136" y="283" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • iLvl 1 • 100 XP</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="112" y="318" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="136" y="351" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 1</text>
|
||||||
|
<text x="136" y="379" fill="#aaa9b7" font-family="monospace" font-size="16">Heroic • iLvl 5 • 140 XP</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="112" y="414" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="136" y="447" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt • Part 1</text>
|
||||||
|
<text x="136" y="475" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • Locked Level 5</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="112" y="510" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="136" y="543" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 2</text>
|
||||||
|
<text x="136" y="571" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • Locked until Part 1 clear</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect x="616" y="150" width="536" height="474" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="650" y="184" width="130" height="130" fill="#481e29" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="684" y="262" fill="#ef6574" font-family="monospace" font-size="42" font-weight="700">AH</text>
|
||||||
|
<text x="812" y="194" fill="#8f90a0" font-family="monospace" font-size="18">SELECTED RUN</text>
|
||||||
|
<text x="812" y="238" fill="#f2f0dc" font-family="monospace" font-size="30" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="812" y="278" fill="#e5b95f" font-family="monospace" font-size="20">Part 1 • Normal</text>
|
||||||
|
<text x="650" y="358" fill="#aaa9b7" font-family="monospace" font-size="17">Fastest path for controller play. One list item is the exact run.</text>
|
||||||
|
<text x="650" y="394" fill="#aaa9b7" font-family="monospace" font-size="17">No separate dungeon, phase, or difficulty controls.</text>
|
||||||
|
|
||||||
|
<rect x="650" y="440" width="470" height="64" fill="#15161c" stroke="#33343d" stroke-width="3"/>
|
||||||
|
<text x="680" y="480" fill="#f2f0dc" font-family="monospace" font-size="18">Health 1.00x Damage 1.00x XP 1.0x</text>
|
||||||
|
<rect x="650" y="532" width="220" height="62" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="716" y="570" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Start</text>
|
||||||
|
<rect x="900" y="532" width="220" height="62" fill="#15161c" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="955" y="570" fill="#e5b95f" font-family="monospace" font-size="22">Loot</text>
|
||||||
|
|
||||||
|
<text x="78" y="684" fill="#aaa9b7" font-family="monospace" font-size="17">Idea C: Flat mission list. Most controller-friendly, least setup flexibility on one screen.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,72 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
|
||||||
|
<text x="958" y="88" fill="#e5b95f" font-family="monospace" font-size="18">B Back • A Select</text>
|
||||||
|
|
||||||
|
<text x="78" y="158" fill="#e5b95f" font-family="monospace" font-size="18">1. Pick Item Level</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="125" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">5</text>
|
||||||
|
<text x="108" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Initiate</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="268" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="310" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">10</text>
|
||||||
|
<text x="300" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Veteran</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="458" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="500" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">15</text>
|
||||||
|
<text x="488" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Champion</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="648" y="178" width="170" height="72" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="690" y="222" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">20</text>
|
||||||
|
<text x="690" y="240" fill="#e5b95f" font-family="monospace" font-size="12">Mythic</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="838" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="880" y="222" fill="#777988" font-family="monospace" font-size="22">25</text>
|
||||||
|
<text x="864" y="240" fill="#777988" font-family="monospace" font-size="12">Level 20</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="304" fill="#e5b95f" font-family="monospace" font-size="18">2. Pick Run</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="324" width="335" height="154" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<rect x="96" y="346" width="64" height="64" fill="#481e29" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="114" y="387" fill="#ef6574" font-family="monospace" font-size="24" font-weight="700">AH</text>
|
||||||
|
<text x="178" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="178" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
|
||||||
|
<text x="96" y="446" fill="#e5b95f" font-family="monospace" font-size="16">Part 1 unlocked</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="436" y="324" width="335" height="154" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="454" y="346" width="64" height="64" fill="#2a263f" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="472" y="387" fill="#8ca9ff" font-family="monospace" font-size="24" font-weight="700">SC</text>
|
||||||
|
<text x="536" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt</text>
|
||||||
|
<text x="536" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
|
||||||
|
<text x="454" y="446" fill="#aaa9b7" font-family="monospace" font-size="16">Part 1 unlocked</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="794" y="324" width="335" height="154" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="812" y="346" width="64" height="64" fill="#223226" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="830" y="387" fill="#70d990" font-family="monospace" font-size="24" font-weight="700">GM</text>
|
||||||
|
<text x="894" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Grove Maw</text>
|
||||||
|
<text x="894" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
|
||||||
|
<text x="812" y="446" fill="#777988" font-family="monospace" font-size="16">Locked dungeon</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="532" fill="#e5b95f" font-family="monospace" font-size="18">3. Pick Part</text>
|
||||||
|
<rect x="78" y="552" width="250" height="64" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="124" y="592" fill="#f2f0dc" font-family="monospace" font-size="20">Part 1</text>
|
||||||
|
<rect x="352" y="552" width="250" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="398" y="592" fill="#aaa9b7" font-family="monospace" font-size="20">Part 2</text>
|
||||||
|
<rect x="626" y="552" width="250" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="672" y="592" fill="#aaa9b7" font-family="monospace" font-size="20">Part 3</text>
|
||||||
|
<rect x="926" y="552" width="226" height="64" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="991" y="592" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Start</text>
|
||||||
|
|
||||||
|
<text x="78" y="662" fill="#aaa9b7" font-family="monospace" font-size="17">Idea D: submenu flow. Pick item level first, then only compatible dungeon cards appear.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
@@ -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 |
@@ -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"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdirSync } from 'node:fs'
|
import { existsSync, mkdirSync } from 'node:fs'
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import { DatabaseSync } from 'node:sqlite'
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ mkdirSync('data', { recursive: true })
|
|||||||
const database = new DatabaseSync('data/game.db')
|
const database = new DatabaseSync('data/game.db')
|
||||||
const schema = await readFile(new URL('../db/schema.sql', import.meta.url), 'utf8')
|
const schema = await readFile(new URL('../db/schema.sql', import.meta.url), 'utf8')
|
||||||
const seed = await readFile(new URL('../db/seed.sql', import.meta.url), 'utf8')
|
const seed = await readFile(new URL('../db/seed.sql', import.meta.url), 'utf8')
|
||||||
|
const adminOverridesUrl = new URL('../db/admin-overrides.sql', import.meta.url)
|
||||||
|
|
||||||
database.exec(schema)
|
database.exec(schema)
|
||||||
|
|
||||||
@@ -205,6 +206,7 @@ addColumnIfMissing('dungeons', 'content_type', "TEXT NOT NULL DEFAULT 'dungeon'"
|
|||||||
addColumnIfMissing('dungeons', 'party_size', 'INTEGER NOT NULL DEFAULT 5')
|
addColumnIfMissing('dungeons', 'party_size', 'INTEGER NOT NULL DEFAULT 5')
|
||||||
addColumnIfMissing('dungeons', 'completion_item_level', 'INTEGER')
|
addColumnIfMissing('dungeons', 'completion_item_level', 'INTEGER')
|
||||||
addColumnIfMissing('dungeons', 'experience_reward', 'INTEGER NOT NULL DEFAULT 100')
|
addColumnIfMissing('dungeons', 'experience_reward', 'INTEGER NOT NULL DEFAULT 100')
|
||||||
|
addColumnIfMissing('dungeons', 'image_url', "TEXT NOT NULL DEFAULT '/boss-placeholder.svg'")
|
||||||
addColumnIfMissing('classes', 'theme_color', "TEXT NOT NULL DEFAULT '#e5b95f'")
|
addColumnIfMissing('classes', 'theme_color', "TEXT NOT NULL DEFAULT '#e5b95f'")
|
||||||
addColumnIfMissing('spells', 'unlock_level', 'INTEGER NOT NULL DEFAULT 1')
|
addColumnIfMissing('spells', 'unlock_level', 'INTEGER NOT NULL DEFAULT 1')
|
||||||
addColumnIfMissing('spells', 'glyph', "TEXT NOT NULL DEFAULT '+'")
|
addColumnIfMissing('spells', 'glyph', "TEXT NOT NULL DEFAULT '+'")
|
||||||
@@ -249,6 +251,11 @@ addColumnIfMissing('items', 'image_url', "TEXT NOT NULL DEFAULT '/equipment-plac
|
|||||||
|
|
||||||
database.exec(seed)
|
database.exec(seed)
|
||||||
|
|
||||||
|
if (existsSync(adminOverridesUrl)) {
|
||||||
|
const adminOverrides = await readFile(adminOverridesUrl, 'utf8')
|
||||||
|
database.exec(adminOverrides)
|
||||||
|
}
|
||||||
|
|
||||||
const counts = database
|
const counts = database
|
||||||
.prepare(`
|
.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ const host = '127.0.0.1'
|
|||||||
const port = Number(process.env.ADMIN_PORT ?? 4174)
|
const port = Number(process.env.ADMIN_PORT ?? 4174)
|
||||||
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
|
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
|
||||||
const distPath = fileURLToPath(new URL('../dist', import.meta.url))
|
const distPath = fileURLToPath(new URL('../dist', import.meta.url))
|
||||||
|
const adminOverridesPath = fileURLToPath(new URL('../db/admin-overrides.sql', import.meta.url))
|
||||||
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
|
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
|
||||||
const itemImageDirectory = fileURLToPath(new URL('../data/uploads/items/', import.meta.url))
|
const itemImageDirectory = fileURLToPath(new URL('../data/uploads/items/', import.meta.url))
|
||||||
|
const dungeonImageDirectory = fileURLToPath(new URL('../data/uploads/dungeons/', import.meta.url))
|
||||||
|
|
||||||
const bossImageContentTypes = {
|
const bossImageContentTypes = {
|
||||||
'.gif': 'image/gif',
|
'.gif': 'image/gif',
|
||||||
@@ -26,6 +28,88 @@ function sendJson(response, status, body) {
|
|||||||
response.end(JSON.stringify(body))
|
response.end(JSON.stringify(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sqlValue(value) {
|
||||||
|
if (value === null || value === undefined) return 'NULL'
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL'
|
||||||
|
return `'${String(value).replaceAll("'", "''")}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAdminOverrides(database) {
|
||||||
|
const lines = [
|
||||||
|
'-- Generated by local admin panel. Commit this file with uploaded art changes.',
|
||||||
|
'PRAGMA foreign_keys = ON;',
|
||||||
|
'BEGIN TRANSACTION;',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const dungeon of database.prepare(`
|
||||||
|
SELECT id, slug, name, recommended_level AS recommendedLevel,
|
||||||
|
content_type AS contentType, party_size AS partySize,
|
||||||
|
experience_reward AS experienceReward, image_url AS imageUrl, description
|
||||||
|
FROM dungeons ORDER BY id
|
||||||
|
`).all()) {
|
||||||
|
lines.push(`UPDATE dungeons SET slug = ${sqlValue(dungeon.slug)}, name = ${sqlValue(dungeon.name)}, recommended_level = ${sqlValue(dungeon.recommendedLevel)}, content_type = ${sqlValue(dungeon.contentType)}, party_size = ${sqlValue(dungeon.partySize)}, experience_reward = ${sqlValue(dungeon.experienceReward)}, image_url = ${sqlValue(dungeon.imageUrl)}, description = ${sqlValue(dungeon.description)} WHERE id = ${sqlValue(dungeon.id)};`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
for (const encounter of database.prepare(`
|
||||||
|
SELECT id, slug, name, encounter_type AS encounterType,
|
||||||
|
max_health AS maxHealth, base_damage AS baseDamage,
|
||||||
|
tank_damage AS tankDamage, party_damage AS partyDamage,
|
||||||
|
description, image_url AS imageUrl
|
||||||
|
FROM encounters ORDER BY id
|
||||||
|
`).all()) {
|
||||||
|
lines.push(`UPDATE encounters SET slug = ${sqlValue(encounter.slug)}, name = ${sqlValue(encounter.name)}, encounter_type = ${sqlValue(encounter.encounterType)}, max_health = ${sqlValue(encounter.maxHealth)}, base_damage = ${sqlValue(encounter.baseDamage)}, tank_damage = ${sqlValue(encounter.tankDamage)}, party_damage = ${sqlValue(encounter.partyDamage)}, description = ${sqlValue(encounter.description)}, image_url = ${sqlValue(encounter.imageUrl)} WHERE id = ${sqlValue(encounter.id)};`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
for (const item of database.prepare(`
|
||||||
|
SELECT id, slug, name, slot, rarity, item_level AS itemLevel,
|
||||||
|
healing_power AS healingPower, max_resource_bonus AS maxResourceBonus,
|
||||||
|
glyph, image_url AS imageUrl, description
|
||||||
|
FROM items ORDER BY id
|
||||||
|
`).all()) {
|
||||||
|
lines.push(`UPDATE items SET slug = ${sqlValue(item.slug)}, name = ${sqlValue(item.name)}, slot = ${sqlValue(item.slot)}, rarity = ${sqlValue(item.rarity)}, item_level = ${sqlValue(item.itemLevel)}, healing_power = ${sqlValue(item.healingPower)}, max_resource_bonus = ${sqlValue(item.maxResourceBonus)}, glyph = ${sqlValue(item.glyph)}, image_url = ${sqlValue(item.imageUrl)}, description = ${sqlValue(item.description)} WHERE id = ${sqlValue(item.id)};`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', 'DELETE FROM encounter_loot;')
|
||||||
|
for (const loot of database.prepare(`
|
||||||
|
SELECT encounter_id AS encounterId, item_id AS itemId,
|
||||||
|
difficulty_id AS difficultyId, drop_weight AS dropWeight, drop_chance AS dropChance
|
||||||
|
FROM encounter_loot ORDER BY encounter_id, difficulty_id, item_id
|
||||||
|
`).all()) {
|
||||||
|
lines.push(`INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (${sqlValue(loot.encounterId)}, ${sqlValue(loot.itemId)}, ${sqlValue(loot.difficultyId)}, ${sqlValue(loot.dropWeight)}, ${sqlValue(loot.dropChance)});`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
for (const recipe of database.prepare(`
|
||||||
|
SELECT id, difficulty_id AS difficultyId,
|
||||||
|
source_dungeon_id AS sourceDungeonId, source_encounter_id AS sourceEncounterId
|
||||||
|
FROM crafting_recipes ORDER BY id
|
||||||
|
`).all()) {
|
||||||
|
lines.push(`UPDATE crafting_recipes SET difficulty_id = ${sqlValue(recipe.difficultyId)}, source_dungeon_id = ${sqlValue(recipe.sourceDungeonId)}, source_encounter_id = ${sqlValue(recipe.sourceEncounterId)} WHERE id = ${sqlValue(recipe.id)};`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', 'DELETE FROM crafting_recipe_components;')
|
||||||
|
for (const component of database.prepare(`
|
||||||
|
SELECT recipe_id AS recipeId, item_id AS itemId, quantity
|
||||||
|
FROM crafting_recipe_components ORDER BY recipe_id, item_id
|
||||||
|
`).all()) {
|
||||||
|
lines.push(`INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (${sqlValue(component.recipeId)}, ${sqlValue(component.itemId)}, ${sqlValue(component.quantity)});`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', 'DELETE FROM gear_upgrade_paths;')
|
||||||
|
for (const path of database.prepare(`
|
||||||
|
SELECT from_item_id AS fromItemId, to_item_id AS toItemId
|
||||||
|
FROM gear_upgrade_paths ORDER BY from_item_id
|
||||||
|
`).all()) {
|
||||||
|
lines.push(`INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (${sqlValue(path.fromItemId)}, ${sqlValue(path.toItemId)});`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', 'COMMIT;', '')
|
||||||
|
writeFileSync(adminOverridesPath, lines.join('\n'), { mode: 0o644 })
|
||||||
|
}
|
||||||
|
|
||||||
async function readJson(request, maxSize = 16 * 1024) {
|
async function readJson(request, maxSize = 16 * 1024) {
|
||||||
const chunks = []
|
const chunks = []
|
||||||
let size = 0
|
let size = 0
|
||||||
@@ -83,14 +167,33 @@ function sendItemImage(request, response) {
|
|||||||
createReadStream(imagePath).pipe(response)
|
createReadStream(imagePath).pipe(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendDungeonImage(request, response) {
|
||||||
|
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
|
||||||
|
const filename = pathname.replace('/api/dungeon-images/', '')
|
||||||
|
if (!/^[A-Za-z0-9._-]+$/.test(filename)) {
|
||||||
|
sendJson(response, 404, { error: 'Image not found.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const imagePath = resolve(dungeonImageDirectory, filename)
|
||||||
|
const insideDirectory = imagePath.startsWith(resolve(dungeonImageDirectory) + sep)
|
||||||
|
const extension = extname(imagePath).toLowerCase()
|
||||||
|
if (!insideDirectory || !bossImageContentTypes[extension] || !existsSync(imagePath) || !statSync(imagePath).isFile()) {
|
||||||
|
sendJson(response, 404, { error: 'Image not found.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.statusCode = 200
|
||||||
|
response.setHeader('Content-Type', bossImageContentTypes[extension])
|
||||||
|
response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff')
|
||||||
|
createReadStream(imagePath).pipe(response)
|
||||||
|
}
|
||||||
|
|
||||||
function saveBossImage(database, encounterId, payload) {
|
function saveBossImage(database, encounterId, payload) {
|
||||||
const encounter = database.prepare(`
|
const encounter = database.prepare(`
|
||||||
SELECT id, slug, encounter_type AS encounterType
|
SELECT id, slug, encounter_type AS encounterType
|
||||||
FROM encounters WHERE id = ?
|
FROM encounters WHERE id = ?
|
||||||
`).get(encounterId)
|
`).get(encounterId)
|
||||||
if (!encounter || encounter.encounterType !== 'boss') {
|
if (!encounter) throw new Error('Encounter not found.')
|
||||||
throw new Error('Boss encounter not found.')
|
|
||||||
}
|
|
||||||
const dataUrl = String(payload.imageData ?? '')
|
const dataUrl = String(payload.imageData ?? '')
|
||||||
const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/)
|
const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/)
|
||||||
if (!match) throw new Error('Upload a PNG, JPG, WebP, or GIF image.')
|
if (!match) throw new Error('Upload a PNG, JPG, WebP, or GIF image.')
|
||||||
@@ -126,6 +229,25 @@ function saveItemImage(database, itemId, payload) {
|
|||||||
return imageUrl
|
return imageUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveDungeonImage(database, dungeonId, payload) {
|
||||||
|
const dungeon = database.prepare(`SELECT id, slug FROM dungeons WHERE id = ?`).get(dungeonId)
|
||||||
|
if (!dungeon) throw new Error('Dungeon not found.')
|
||||||
|
const dataUrl = String(payload.imageData ?? '')
|
||||||
|
const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/)
|
||||||
|
if (!match) throw new Error('Upload a PNG, JPG, WebP, or GIF image.')
|
||||||
|
const extensionByType = { 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }
|
||||||
|
const bytes = Buffer.from(match[2], 'base64')
|
||||||
|
if (bytes.length === 0 || bytes.length > 4 * 1024 * 1024) {
|
||||||
|
throw new Error('Dungeon image must be 1 byte to 4 MB.')
|
||||||
|
}
|
||||||
|
mkdirSync(dungeonImageDirectory, { recursive: true })
|
||||||
|
const filename = `${dungeon.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}`
|
||||||
|
writeFileSync(resolve(dungeonImageDirectory, filename), bytes, { mode: 0o644 })
|
||||||
|
const imageUrl = `/api/dungeon-images/${filename}`
|
||||||
|
database.prepare(`UPDATE dungeons SET image_url = ? WHERE id = ?`).run(imageUrl, dungeonId)
|
||||||
|
return imageUrl
|
||||||
|
}
|
||||||
|
|
||||||
function sendFile(response, filePath) {
|
function sendFile(response, filePath) {
|
||||||
const contentTypes = {
|
const contentTypes = {
|
||||||
'.css': 'text/css; charset=utf-8',
|
'.css': 'text/css; charset=utf-8',
|
||||||
@@ -181,6 +303,11 @@ const server = createServer(async (request, response) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.url.startsWith('/api/dungeon-images/') && request.method === 'GET') {
|
||||||
|
sendDungeonImage(request, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!existsSync(databasePath)) {
|
if (!existsSync(databasePath)) {
|
||||||
sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
|
sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
|
||||||
return
|
return
|
||||||
@@ -201,7 +328,9 @@ const server = createServer(async (request, response) => {
|
|||||||
`).all()
|
`).all()
|
||||||
const encounters = database.prepare(`
|
const encounters = database.prepare(`
|
||||||
SELECT id, dungeon_id AS dungeonId, sequence, slug, name AS enemyName,
|
SELECT id, dungeon_id AS dungeonId, sequence, slug, name AS enemyName,
|
||||||
encounter_type AS encounterType, image_url AS imageUrl
|
encounter_type AS encounterType, max_health AS maxHealth, base_damage AS baseDamage,
|
||||||
|
tank_damage AS tankDamage, party_damage AS partyDamage,
|
||||||
|
description, image_url AS imageUrl
|
||||||
FROM encounters ORDER BY dungeon_id, sequence
|
FROM encounters ORDER BY dungeon_id, sequence
|
||||||
`).all()
|
`).all()
|
||||||
const difficulties = database.prepare(`
|
const difficulties = database.prepare(`
|
||||||
@@ -235,8 +364,56 @@ const server = createServer(async (request, response) => {
|
|||||||
recipes.get(row.id).components.push({ itemId: row.componentId, quantity: row.quantity })
|
recipes.get(row.id).components.push({ itemId: row.componentId, quantity: row.quantity })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const dungeons = database.prepare(`SELECT id, slug, name FROM dungeons ORDER BY id`).all()
|
const dungeons = database.prepare(`
|
||||||
sendJson(response, 200, { items, encounters, difficulties, encounterLoot, craftingRecipes: [...recipes.values()], dungeons })
|
SELECT id, slug, name, recommended_level AS recommendedLevel,
|
||||||
|
content_type AS contentType, party_size AS partySize,
|
||||||
|
experience_reward AS experienceReward, image_url AS imageUrl,
|
||||||
|
description
|
||||||
|
FROM dungeons ORDER BY id
|
||||||
|
`).all()
|
||||||
|
const gearUpgradePaths = database.prepare(`
|
||||||
|
SELECT from_item_id AS fromItemId, to_item_id AS toItemId
|
||||||
|
FROM gear_upgrade_paths ORDER BY from_item_id
|
||||||
|
`).all()
|
||||||
|
const classes = database.prepare(`
|
||||||
|
SELECT id, slug, name, resource_name AS resourceName,
|
||||||
|
max_resource AS maxResource, theme_color AS themeColor, description
|
||||||
|
FROM classes ORDER BY id
|
||||||
|
`).all()
|
||||||
|
const abilities = database.prepare(`
|
||||||
|
SELECT id, class_id AS classId, slug, name, spell_type AS spellType,
|
||||||
|
resource_cost AS cost, cooldown_seconds AS cooldown, power,
|
||||||
|
unlock_level AS unlockLevel, glyph, description
|
||||||
|
FROM spells ORDER BY class_id, unlock_level, id
|
||||||
|
`).all()
|
||||||
|
const talents = database.prepare(`
|
||||||
|
SELECT talents.id, talents.class_id AS classId, talents.slug, talents.name,
|
||||||
|
talents.max_rank AS maxRank, talents.tier, talents.branch,
|
||||||
|
talents.prerequisite_talent_id AS prerequisiteTalentId,
|
||||||
|
talents.prerequisite_rank AS prerequisiteRank,
|
||||||
|
prerequisite.name AS prerequisiteName,
|
||||||
|
talents.effect_type AS effectType,
|
||||||
|
talents.effect_value_per_rank AS effectValuePerRank,
|
||||||
|
talents.glyph, talents.description
|
||||||
|
FROM talents
|
||||||
|
LEFT JOIN talents AS prerequisite
|
||||||
|
ON prerequisite.id = talents.prerequisite_talent_id
|
||||||
|
ORDER BY talents.class_id, talents.tier, talents.branch
|
||||||
|
`).all()
|
||||||
|
sendJson(response, 200, {
|
||||||
|
items,
|
||||||
|
encounters,
|
||||||
|
difficulties,
|
||||||
|
encounterLoot,
|
||||||
|
craftingRecipes: [...recipes.values()],
|
||||||
|
dungeons,
|
||||||
|
gearUpgradePaths,
|
||||||
|
classes: classes.map((gameClass) => ({
|
||||||
|
...gameClass,
|
||||||
|
abilities: abilities.filter((ability) => ability.classId === gameClass.id),
|
||||||
|
talents: talents.filter((talent) => talent.classId === gameClass.id),
|
||||||
|
})),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +421,7 @@ const server = createServer(async (request, response) => {
|
|||||||
if (bossImageMatch && request.method === 'PUT') {
|
if (bossImageMatch && request.method === 'PUT') {
|
||||||
const payload = await readJson(request, 6 * 1024 * 1024)
|
const payload = await readJson(request, 6 * 1024 * 1024)
|
||||||
const imageUrl = saveBossImage(database, Number(bossImageMatch[1]), payload)
|
const imageUrl = saveBossImage(database, Number(bossImageMatch[1]), payload)
|
||||||
|
writeAdminOverrides(database)
|
||||||
sendJson(response, 200, { ok: true, imageUrl })
|
sendJson(response, 200, { ok: true, imageUrl })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -252,6 +430,16 @@ const server = createServer(async (request, response) => {
|
|||||||
if (itemImageMatch && request.method === 'PUT') {
|
if (itemImageMatch && request.method === 'PUT') {
|
||||||
const payload = await readJson(request, 6 * 1024 * 1024)
|
const payload = await readJson(request, 6 * 1024 * 1024)
|
||||||
const imageUrl = saveItemImage(database, Number(itemImageMatch[1]), payload)
|
const imageUrl = saveItemImage(database, Number(itemImageMatch[1]), payload)
|
||||||
|
writeAdminOverrides(database)
|
||||||
|
sendJson(response, 200, { ok: true, imageUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dungeonImageMatch = request.url.match(/^\/api\/admin\/dungeons\/(\d+)\/image$/)
|
||||||
|
if (dungeonImageMatch && request.method === 'PUT') {
|
||||||
|
const payload = await readJson(request, 6 * 1024 * 1024)
|
||||||
|
const imageUrl = saveDungeonImage(database, Number(dungeonImageMatch[1]), payload)
|
||||||
|
writeAdminOverrides(database)
|
||||||
sendJson(response, 200, { ok: true, imageUrl })
|
sendJson(response, 200, { ok: true, imageUrl })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -271,6 +459,47 @@ const server = createServer(async (request, response) => {
|
|||||||
if (fields.length === 0) { sendJson(response, 400, { error: 'No fields to update.' }); return }
|
if (fields.length === 0) { sendJson(response, 400, { error: 'No fields to update.' }); return }
|
||||||
values.push(itemId)
|
values.push(itemId)
|
||||||
database.prepare(`UPDATE items SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
database.prepare(`UPDATE items SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||||
|
writeAdminOverrides(database)
|
||||||
|
sendJson(response, 200, { ok: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dungeonMatch = request.url.match(/^\/api\/admin\/dungeons\/(\d+)$/)
|
||||||
|
if (dungeonMatch && request.method === 'PUT') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
const dungeonId = Number(dungeonMatch[1])
|
||||||
|
const fields = []
|
||||||
|
const values = []
|
||||||
|
for (const key of ['name', 'slug', 'content_type', 'description']) {
|
||||||
|
if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(payload[key]) }
|
||||||
|
}
|
||||||
|
for (const key of ['recommended_level', 'party_size', 'experience_reward']) {
|
||||||
|
if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(Number(payload[key])) }
|
||||||
|
}
|
||||||
|
if (fields.length === 0) { sendJson(response, 400, { error: 'No fields to update.' }); return }
|
||||||
|
values.push(dungeonId)
|
||||||
|
database.prepare(`UPDATE dungeons SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||||
|
writeAdminOverrides(database)
|
||||||
|
sendJson(response, 200, { ok: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const encounterMatch = request.url.match(/^\/api\/admin\/encounters\/(\d+)$/)
|
||||||
|
if (encounterMatch && request.method === 'PUT') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
const encounterId = Number(encounterMatch[1])
|
||||||
|
const fields = []
|
||||||
|
const values = []
|
||||||
|
for (const key of ['name', 'slug', 'encounter_type', 'description']) {
|
||||||
|
if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(payload[key]) }
|
||||||
|
}
|
||||||
|
for (const key of ['max_health', 'base_damage', 'tank_damage', 'party_damage']) {
|
||||||
|
if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(Number(payload[key])) }
|
||||||
|
}
|
||||||
|
if (fields.length === 0) { sendJson(response, 400, { error: 'No fields to update.' }); return }
|
||||||
|
values.push(encounterId)
|
||||||
|
database.prepare(`UPDATE encounters SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||||
|
writeAdminOverrides(database)
|
||||||
sendJson(response, 200, { ok: true })
|
sendJson(response, 200, { ok: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -283,6 +512,7 @@ const server = createServer(async (request, response) => {
|
|||||||
ON CONFLICT(encounter_id, difficulty_id, item_id)
|
ON CONFLICT(encounter_id, difficulty_id, item_id)
|
||||||
DO UPDATE SET drop_weight = excluded.drop_weight, drop_chance = excluded.drop_chance
|
DO UPDATE SET drop_weight = excluded.drop_weight, drop_chance = excluded.drop_chance
|
||||||
`).run(payload.encounterId, payload.itemId, payload.difficultyId, payload.dropWeight ?? 100, payload.dropChance ?? 0.65)
|
`).run(payload.encounterId, payload.itemId, payload.difficultyId, payload.dropWeight ?? 100, payload.dropChance ?? 0.65)
|
||||||
|
writeAdminOverrides(database)
|
||||||
sendJson(response, 200, { ok: true })
|
sendJson(response, 200, { ok: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -291,6 +521,7 @@ const server = createServer(async (request, response) => {
|
|||||||
if (lootDelete && request.method === 'DELETE') {
|
if (lootDelete && request.method === 'DELETE') {
|
||||||
database.prepare(`DELETE FROM encounter_loot WHERE encounter_id = ? AND difficulty_id = ? AND item_id = ?`)
|
database.prepare(`DELETE FROM encounter_loot WHERE encounter_id = ? AND difficulty_id = ? AND item_id = ?`)
|
||||||
.run(Number(lootDelete[1]), Number(lootDelete[2]), Number(lootDelete[3]))
|
.run(Number(lootDelete[1]), Number(lootDelete[2]), Number(lootDelete[3]))
|
||||||
|
writeAdminOverrides(database)
|
||||||
sendJson(response, 200, { ok: true })
|
sendJson(response, 200, { ok: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -298,12 +529,33 @@ const server = createServer(async (request, response) => {
|
|||||||
const recipeComponents = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)\/components$/)
|
const recipeComponents = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)\/components$/)
|
||||||
if (recipeComponents && request.method === 'POST') {
|
if (recipeComponents && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
|
const quantity = Number(payload.quantity)
|
||||||
|
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Component quantity must be at least 1.')
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON CONFLICT(recipe_id, item_id)
|
ON CONFLICT(recipe_id, item_id)
|
||||||
DO UPDATE SET quantity = excluded.quantity
|
DO UPDATE SET quantity = excluded.quantity
|
||||||
`).run(Number(recipeComponents[1]), payload.itemId, payload.quantity)
|
`).run(Number(recipeComponents[1]), payload.itemId, quantity)
|
||||||
|
writeAdminOverrides(database)
|
||||||
|
sendJson(response, 200, { ok: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeMatch = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)$/)
|
||||||
|
if (recipeMatch && request.method === 'PUT') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET difficulty_id = ?, source_dungeon_id = ?, source_encounter_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
payload.difficultyId ? Number(payload.difficultyId) : null,
|
||||||
|
payload.sourceDungeonId ? Number(payload.sourceDungeonId) : null,
|
||||||
|
payload.sourceEncounterId ? Number(payload.sourceEncounterId) : null,
|
||||||
|
Number(recipeMatch[1]),
|
||||||
|
)
|
||||||
|
writeAdminOverrides(database)
|
||||||
sendJson(response, 200, { ok: true })
|
sendJson(response, 200, { ok: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -312,6 +564,30 @@ const server = createServer(async (request, response) => {
|
|||||||
if (recipeComponentDelete && request.method === 'DELETE') {
|
if (recipeComponentDelete && request.method === 'DELETE') {
|
||||||
database.prepare(`DELETE FROM crafting_recipe_components WHERE recipe_id = ? AND item_id = ?`)
|
database.prepare(`DELETE FROM crafting_recipe_components WHERE recipe_id = ? AND item_id = ?`)
|
||||||
.run(Number(recipeComponentDelete[1]), Number(recipeComponentDelete[2]))
|
.run(Number(recipeComponentDelete[1]), Number(recipeComponentDelete[2]))
|
||||||
|
writeAdminOverrides(database)
|
||||||
|
sendJson(response, 200, { ok: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/admin/gear-upgrade-paths' && request.method === 'POST') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
const fromItemId = Number(payload.fromItemId)
|
||||||
|
const toItemId = Number(payload.toItemId)
|
||||||
|
if (!fromItemId || !toItemId || fromItemId === toItemId) throw new Error('Choose two different equipment items.')
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(from_item_id) DO UPDATE SET to_item_id = excluded.to_item_id
|
||||||
|
`).run(fromItemId, toItemId)
|
||||||
|
writeAdminOverrides(database)
|
||||||
|
sendJson(response, 200, { ok: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgradePathDelete = request.url.match(/^\/api\/admin\/gear-upgrade-paths\/(\d+)$/)
|
||||||
|
if (upgradePathDelete && request.method === 'DELETE') {
|
||||||
|
database.prepare(`DELETE FROM gear_upgrade_paths WHERE from_item_id = ?`).run(Number(upgradePathDelete[1]))
|
||||||
|
writeAdminOverrides(database)
|
||||||
sendJson(response, 200, { ok: true })
|
sendJson(response, 200, { ok: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
})
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +590,7 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
dungeons.party_size AS partySize,
|
dungeons.party_size AS partySize,
|
||||||
dungeons.completion_item_level AS completionItemLevel,
|
dungeons.completion_item_level AS completionItemLevel,
|
||||||
dungeons.experience_reward AS experienceReward,
|
dungeons.experience_reward AS experienceReward,
|
||||||
|
dungeons.image_url AS imageUrl,
|
||||||
dungeons.description,
|
dungeons.description,
|
||||||
locations.name AS locationName
|
locations.name AS locationName
|
||||||
FROM dungeons
|
FROM dungeons
|
||||||
@@ -699,6 +749,11 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
...bonus,
|
...bonus,
|
||||||
active: bonus.equippedPieces >= bonus.requiredPieces,
|
active: bonus.equippedPieces >= bonus.requiredPieces,
|
||||||
}))
|
}))
|
||||||
|
const gearUpgradePaths = database.prepare(`
|
||||||
|
SELECT from_item_id AS fromItemId, to_item_id AS toItemId
|
||||||
|
FROM gear_upgrade_paths
|
||||||
|
ORDER BY from_item_id
|
||||||
|
`).all()
|
||||||
const leaderboardRuns = database.prepare(`
|
const leaderboardRuns = database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
rank,
|
rank,
|
||||||
@@ -741,6 +796,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()
|
||||||
@@ -785,6 +849,7 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
inventory,
|
inventory,
|
||||||
gearStats,
|
gearStats,
|
||||||
setBonuses,
|
setBonuses,
|
||||||
|
gearUpgradePaths,
|
||||||
craftingRecipes: craftingRecipeRows.map((recipe) => {
|
craftingRecipes: craftingRecipeRows.map((recipe) => {
|
||||||
const components = craftingComponentRows
|
const components = craftingComponentRows
|
||||||
.filter((component) => component.recipeId === recipe.id)
|
.filter((component) => component.recipeId === recipe.id)
|
||||||
@@ -793,6 +858,8 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
quantity,
|
quantity,
|
||||||
owned,
|
owned,
|
||||||
}))
|
}))
|
||||||
|
const hasRequiredComponents = components.length > 0
|
||||||
|
&& components.every((component) => component.quantity > 0)
|
||||||
const { itemId, slug, name, slot, rarity, itemLevel, healingPower, maxResourceBonus, glyph, description, setId, setSlug, setName } = recipe
|
const { itemId, slug, name, slot, rarity, itemLevel, healingPower, maxResourceBonus, glyph, description, setId, setSlug, setName } = recipe
|
||||||
return {
|
return {
|
||||||
id: recipe.id,
|
id: recipe.id,
|
||||||
@@ -815,11 +882,13 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
setName,
|
setName,
|
||||||
},
|
},
|
||||||
components,
|
components,
|
||||||
canCraft: components.every((component) => component.owned >= component.quantity),
|
canCraft: hasRequiredComponents
|
||||||
|
&& components.every((component) => component.owned >= component.quantity),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
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 +1337,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 +1490,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 +1725,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
|
||||||
@@ -1630,6 +1749,9 @@ function craftItem(database, characterId, recipeId) {
|
|||||||
WHERE crafting_recipe_components.recipe_id = ?
|
WHERE crafting_recipe_components.recipe_id = ?
|
||||||
`).all(characterId, recipeId)
|
`).all(characterId, recipeId)
|
||||||
if (components.length === 0) throw new Error('That recipe has no component requirements.')
|
if (components.length === 0) throw new Error('That recipe has no component requirements.')
|
||||||
|
if (components.some((component) => component.quantity <= 0)) {
|
||||||
|
throw new Error('Recipe components must require at least one item.')
|
||||||
|
}
|
||||||
const missing = components.find((component) => component.owned < component.quantity)
|
const missing = components.find((component) => component.owned < component.quantity)
|
||||||
if (missing) {
|
if (missing) {
|
||||||
const item = itemById(database, missing.itemId)
|
const item = itemById(database, missing.itemId)
|
||||||
@@ -1665,9 +1787,137 @@ 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 id
|
||||||
|
FROM crafting_recipes
|
||||||
|
WHERE item_id = ?
|
||||||
|
`).get(itemId)
|
||||||
|
if (!currentRecipe) throw new Error('No upgrade is available for this item.')
|
||||||
|
|
||||||
|
const pathRecipe = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
crafting_recipes.id,
|
||||||
|
crafting_recipes.item_id AS itemId
|
||||||
|
FROM gear_upgrade_paths
|
||||||
|
JOIN crafting_recipes ON crafting_recipes.item_id = gear_upgrade_paths.to_item_id
|
||||||
|
WHERE gear_upgrade_paths.from_item_id = ?
|
||||||
|
`).get(itemId)
|
||||||
|
|
||||||
|
const targetRecipe = pathRecipe ?? database.prepare(`
|
||||||
|
WITH next_tier AS (
|
||||||
|
SELECT MIN(items.item_level) AS item_level
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
|
WHERE items.slot = ?
|
||||||
|
AND items.item_level > ?
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
crafting_recipes.id,
|
||||||
|
crafting_recipes.item_id AS itemId
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
|
JOIN next_tier ON next_tier.item_level = items.item_level
|
||||||
|
WHERE items.slot = ?
|
||||||
|
ORDER BY crafting_recipes.id
|
||||||
|
LIMIT 1
|
||||||
|
`).get(item.slot, item.itemLevel, item.slot)
|
||||||
|
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)
|
||||||
|
if (components.length === 0) throw new Error('That upgrade has no component requirements.')
|
||||||
|
if (components.some((component) => component.quantity <= 0)) {
|
||||||
|
throw new Error('Upgrade components must require at least one item.')
|
||||||
|
}
|
||||||
|
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 +1929,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 +1938,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 +2070,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)
|
||||||
|
if (character.classId !== 1) {
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
UPDATE characters
|
UPDATE characters
|
||||||
SET talent_points = MIN(level, talent_points + ?)
|
SET talent_points = MIN(level, talent_points + ?)
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(refunded, characterId)
|
`).run(refunded, characterId)
|
||||||
|
}
|
||||||
database.exec('COMMIT')
|
database.exec('COMMIT')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
database.exec('ROLLBACK')
|
database.exec('ROLLBACK')
|
||||||
@@ -1840,12 +2147,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 +2259,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 +2367,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 +2387,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 +2396,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 +2440,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 +2460,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 +2549,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 +2692,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 +2804,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)
|
||||||
|
|||||||
@@ -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,6 +439,28 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
{roguelikeVariant === 'pve' && (
|
{roguelikeVariant === 'pve' && (
|
||||||
<>
|
<>
|
||||||
|
<div className="roguelike-option-panel">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Run Type</p>
|
||||||
|
<h2>PvE Roguelike</h2>
|
||||||
|
</div>
|
||||||
|
<div className="roguelike-timing-row">
|
||||||
|
<button
|
||||||
|
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''}`}
|
||||||
|
onClick={() => setRoguelikeKind('dungeon')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Dungeon
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''}`}
|
||||||
|
onClick={() => setRoguelikeKind('raid')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Raid
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="roguelike-option-panel">
|
<div className="roguelike-option-panel">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Upgrade Timing</p>
|
<p className="eyebrow">Upgrade Timing</p>
|
||||||
@@ -438,38 +505,22 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="roguelike-mode-grid">
|
<div className="menu-card pvp-queue-panel">
|
||||||
|
<span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'}</strong>
|
||||||
|
<small>
|
||||||
|
{roguelikeKind === 'raid'
|
||||||
|
? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
|
||||||
|
: 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="menu-card"
|
className="text-button"
|
||||||
onClick={() => {
|
onClick={startPveRoguelike}
|
||||||
const baseDungeon = dungeonOptions[0]
|
|
||||||
setRoguelikeKind('dungeon')
|
|
||||||
setCombatContentId(-1)
|
|
||||||
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
|
|
||||||
setSelectedPart(1)
|
|
||||||
setScreen('combat')
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>D</span>
|
Start Run
|
||||||
<strong>Dungeon Roguelike</strong>
|
|
||||||
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="menu-card"
|
|
||||||
onClick={() => {
|
|
||||||
const baseRaid = raidOptions[0]
|
|
||||||
setRoguelikeKind('raid')
|
|
||||||
setCombatContentId(-2)
|
|
||||||
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
|
|
||||||
setSelectedPart(1)
|
|
||||||
setScreen('combat')
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span>R</span>
|
|
||||||
<strong>Raid Roguelike</strong>
|
|
||||||
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -555,94 +606,181 @@ 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')}
|
|
||||||
/>
|
|
||||||
<article className="dungeon-card">
|
|
||||||
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
|
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||||
{activityInitials(activity.name)}
|
{activityInitials(activity.name)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="run-summary-copy">
|
||||||
<p className="eyebrow">{activity.locationName}</p>
|
<p className="eyebrow">Selected Run</p>
|
||||||
|
<div className="run-title-row">
|
||||||
<h2>{activity.name}</h2>
|
<h2>{activity.name}</h2>
|
||||||
|
<button className="back-button inline-back-button" onClick={() => setScreen('menu')} type="button">Back</button>
|
||||||
|
</div>
|
||||||
<p>{activity.description}</p>
|
<p>{activity.description}</p>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span>Level {activity.recommendedLevel}</span>
|
<span>Level {activity.recommendedLevel}</span>
|
||||||
<span>{activity.partySize} Players</span>
|
<span>{activity.partySize} Players</span>
|
||||||
<span>{selectedDifficulty.name}</span>
|
<span>{selectedDifficulty.name}</span>
|
||||||
<span>Component Level {selectedDifficulty.droppedItemLevel}</span>
|
<span>iLvl {selectedDifficulty.droppedItemLevel}</span>
|
||||||
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
|
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activityOptions.length > 1 && (
|
</article>
|
||||||
<label className="activity-select">
|
|
||||||
<span>{activity.contentType === 'raid' ? 'Raid' : 'Dungeon'}</span>
|
<section className="run-setup-panel dungeon-choice-panel">
|
||||||
<select
|
<div className="run-setup-heading">
|
||||||
value={activity.id}
|
<div>
|
||||||
onChange={(event) => {
|
<p className="eyebrow">Pick Run</p>
|
||||||
const nextActivityId = Number(event.target.value)
|
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
|
||||||
const nextActivity = activityOptions.find((candidate) => candidate.id === nextActivityId)
|
</div>
|
||||||
if (screen === 'raids') setSelectedRaidId(nextActivityId)
|
{activityPageCount > 1 ? (
|
||||||
else setSelectedDungeonId(nextActivityId)
|
<div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
|
||||||
if (nextActivity?.difficulties[0]) {
|
<button
|
||||||
setSelectedDifficultyId(nextActivity.difficulties[0].id)
|
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>
|
||||||
|
|
||||||
|
<aside className="dungeon-setup-rail">
|
||||||
|
<section className="run-setup-panel tier-setup-panel">
|
||||||
|
<div className="run-setup-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Item Level</p>
|
||||||
|
<h2>Tier</h2>
|
||||||
|
</div>
|
||||||
|
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
|
||||||
|
</div>
|
||||||
|
<div className="tier-grid">
|
||||||
|
{tierOptions.map((difficulty) => {
|
||||||
|
const locked = profile.character.level < difficulty.unlockLevel
|
||||||
|
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||||
|
disabled={locked}
|
||||||
|
key={difficulty.id}
|
||||||
|
onClick={() => {
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
{activityOptions.map((candidate) => (
|
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
||||||
<option key={candidate.id} value={candidate.id}>{candidate.name}</option>
|
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
|
||||||
))}
|
</button>
|
||||||
</select>
|
)
|
||||||
</label>
|
})}
|
||||||
)}
|
</div>
|
||||||
<div className="part-buttons">
|
</section>
|
||||||
{parts.map((p) => (
|
|
||||||
|
<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
|
<button
|
||||||
key={p.part}
|
className="primary-button selected-part"
|
||||||
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
|
disabled={difficultyLocked}
|
||||||
disabled={difficultyLocked || !p.unlocked}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedPart(p.part)
|
setSelectedMarathonMode(false)
|
||||||
setCombatContentId(activity.id)
|
setCombatContentId(activity.id)
|
||||||
|
setSelectedDifficultyId(selectedDifficulty.id)
|
||||||
setScreen('combat')
|
setScreen('combat')
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{p.name}
|
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>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</section>
|
||||||
|
|
||||||
<div className="difficulty-section compact-difficulty-section">
|
<div className="difficulty-section compact-difficulty-section">
|
||||||
<div className="difficulty-select-row">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Challenge Tier</p>
|
|
||||||
<h2>Difficulty</h2>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>Select</span>
|
|
||||||
<select
|
|
||||||
value={selectedDifficulty.id}
|
|
||||||
onChange={(event) => setSelectedDifficultyId(Number(event.target.value))}
|
|
||||||
>
|
|
||||||
{activity.difficulties.map((difficulty, index) => (
|
|
||||||
<option
|
|
||||||
disabled={profile.character.level < difficulty.unlockLevel}
|
|
||||||
key={difficulty.id}
|
|
||||||
value={difficulty.id}
|
|
||||||
>
|
|
||||||
{index + 1}. {difficulty.name}
|
|
||||||
{profile.character.level < difficulty.unlockLevel
|
|
||||||
? ` - Level ${difficulty.unlockLevel}`
|
|
||||||
: ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
|
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{selectedDifficulty.name}</strong>
|
<strong>{selectedDifficulty.name}</strong>
|
||||||
@@ -652,10 +790,11 @@ function App() {
|
|||||||
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
|
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
|
||||||
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
|
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
|
||||||
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
|
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
|
||||||
<div><dt>Components</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
|
<div><dt>Loot</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="loot-preview-section">
|
<div className="loot-preview-section">
|
||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -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">
|
||||||
@@ -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}
|
||||||
@@ -804,6 +943,8 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,49 @@ 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 CraftingRecipe = CharacterProfile['craftingRecipes'][number]
|
||||||
|
|
||||||
|
function selectUpgradeRecipe(
|
||||||
|
paths: CharacterProfile['gearUpgradePaths'],
|
||||||
|
recipes: CraftingRecipe[],
|
||||||
|
item: Pick<Item, 'id' | 'slot' | 'itemLevel'>,
|
||||||
|
) {
|
||||||
|
const path = paths.find((candidate) => candidate.fromItemId === item.id)
|
||||||
|
if (path) {
|
||||||
|
const pathRecipe = recipes.find((recipe) => recipe.item.id === path.toItemId)
|
||||||
|
if (pathRecipe) return pathRecipe
|
||||||
|
}
|
||||||
|
const candidates = recipes.filter((recipe) =>
|
||||||
|
recipe.item.slot === item.slot
|
||||||
|
&& recipe.item.itemLevel > item.itemLevel
|
||||||
|
)
|
||||||
|
const nextItemLevel = Math.min(...candidates.map((recipe) => recipe.item.itemLevel))
|
||||||
|
if (!Number.isFinite(nextItemLevel)) return undefined
|
||||||
|
return candidates.find((recipe) => recipe.item.itemLevel === nextItemLevel)
|
||||||
|
}
|
||||||
|
|
||||||
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 +81,32 @@ 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
|
||||||
|
? selectUpgradeRecipe(profile.gearUpgradePaths ?? [], profile.craftingRecipes, selectedItem)
|
||||||
|
: undefined
|
||||||
const equippedBySlot = useMemo(
|
const equippedBySlot = useMemo(
|
||||||
() => new Map(
|
() => new Map(
|
||||||
profile.inventory
|
profile.inventory
|
||||||
@@ -92,12 +140,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 +155,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 +189,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 +266,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,6 +446,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
|
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showModeTabs && (
|
||||||
<nav className="equipment-tabs">
|
<nav className="equipment-tabs">
|
||||||
<button
|
<button
|
||||||
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
||||||
@@ -231,6 +463,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
Crafting
|
Crafting
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
{equipmentTab === 'equipment' ? (
|
{equipmentTab === 'equipment' ? (
|
||||||
<>
|
<>
|
||||||
@@ -239,9 +472,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 +485,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 +492,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,42 +591,86 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
<section className="crafting-panel">
|
<section className="crafting-panel">
|
||||||
<EquipmentHeading
|
<EquipmentHeading
|
||||||
eyebrow="Crafting"
|
eyebrow="Crafting"
|
||||||
title="Recipes"
|
title="Workbench"
|
||||||
detail={`${filteredRecipes.filter((recipe) => recipe.canCraft).length} ready`}
|
detail={`${readyRecipeCount} ready / ${filteredRecipes.length} shown`}
|
||||||
/>
|
/>
|
||||||
<div className="crafting-filter-bar">
|
|
||||||
<select
|
|
||||||
className="filter-select"
|
|
||||||
value={slotFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSlotFilter(e.target.value as EquipmentSlot | 'all')
|
|
||||||
setRecipePage(0)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="all">All Slots</option>
|
|
||||||
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
|
|
||||||
<option key={slot} value={slot}>{label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="filter-select"
|
|
||||||
value={levelFilter ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
setLevelFilter(e.target.value === '' ? null : Number(e.target.value))
|
|
||||||
setRecipePage(0)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">All Levels</option>
|
|
||||||
{availableLevels.map((level) => (
|
|
||||||
<option key={level} value={level}>Item Level {level}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{filteredRecipes.length === 0 && (
|
|
||||||
<p className="inventory-empty">No crafting recipes match filters.</p>
|
|
||||||
)}
|
|
||||||
{filteredRecipes.length > 0 && (
|
|
||||||
<div className="crafting-layout">
|
<div className="crafting-layout">
|
||||||
|
<aside className="crafting-filters">
|
||||||
|
<EquipmentHeading
|
||||||
|
eyebrow="Slots"
|
||||||
|
title="Gear Slots"
|
||||||
|
detail={slotFilter === 'all' ? 'All' : SLOT_LABELS[slotFilter]}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="crafting-filter-grid">
|
||||||
|
<button
|
||||||
|
className={slotFilter === 'all' ? 'active' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setSlotFilter('all')
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<strong>All</strong>
|
||||||
|
<span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
|
||||||
|
</button>
|
||||||
|
{CRAFTING_FILTER_SLOTS.map((slot) => (
|
||||||
|
<button
|
||||||
|
className={slotFilter === slot ? 'active' : ''}
|
||||||
|
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
|
||||||
|
key={slot}
|
||||||
|
onClick={() => {
|
||||||
|
setSlotFilter(slot)
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<strong>{SLOT_LABELS[slot]}</strong>
|
||||||
|
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Item Level</p>
|
||||||
|
<div className="crafting-level-row">
|
||||||
|
<button
|
||||||
|
className={levelFilter === null ? 'active' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setLevelFilter(null)
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{availableLevels.map((level) => (
|
||||||
|
<button
|
||||||
|
className={levelFilter === level ? 'active' : ''}
|
||||||
|
key={level}
|
||||||
|
onClick={() => {
|
||||||
|
setLevelFilter(level)
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{level}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="crafting-available-panel">
|
||||||
|
<section className="crafting-list-panel">
|
||||||
|
<EquipmentHeading
|
||||||
|
eyebrow="Available Gear"
|
||||||
|
title={slotFilter === 'all' ? 'Craftable Gear' : SLOT_LABELS[slotFilter]}
|
||||||
|
detail={`Page ${recipePage + 1}/${recipePageCount}`}
|
||||||
|
/>
|
||||||
|
{filteredRecipes.length === 0 ? (
|
||||||
|
<p className="inventory-empty">No recipes match filters.</p>
|
||||||
|
) : (
|
||||||
<div className="crafting-list">
|
<div className="crafting-list">
|
||||||
{recipePageItems.map((recipe) => (
|
{recipePageItems.map((recipe) => (
|
||||||
<button
|
<button
|
||||||
@@ -434,9 +687,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,11 +703,30 @@ 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.length === 0 && (
|
||||||
|
<p className="inventory-empty">No materials configured. Crafting disabled.</p>
|
||||||
|
)}
|
||||||
{selectedRecipe.components.map((component) => (
|
{selectedRecipe.components.map((component) => (
|
||||||
<div
|
<div
|
||||||
className={component.owned >= component.quantity ? 'ready' : 'missing'}
|
className={component.owned >= component.quantity ? 'ready' : 'missing'}
|
||||||
@@ -462,22 +738,17 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
disabled={!selectedRecipe.canCraft || crafting}
|
|
||||||
onClick={craftSelected}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{crafting ? 'Crafting...' : 'Craft Item'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="inventory-empty">Select a recipe.</p>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{profile.setBonuses.length > 0 && (
|
{equipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
|
||||||
<section className="set-bonus-panel">
|
<section className="set-bonus-panel">
|
||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -513,11 +784,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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<h2>Spell effects coming soon for {gameClass.name}.</h2>
|
||||||
|
<p>This replacement system starts with the first class.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="spell-effect-layout">
|
||||||
|
<section className="effect-slots-panel">
|
||||||
|
<p className="eyebrow">Active Slots</p>
|
||||||
|
{EFFECT_SLOT_LEVELS.map((level, index) => {
|
||||||
|
const effect = selectedEffects[index]
|
||||||
|
const unlocked = profile.character.level >= level
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
aria-selected={talentPage === index}
|
className={`effect-slot ${effect ? 'filled' : ''} ${unlocked ? '' : 'locked'}`}
|
||||||
className={talentPage === index ? 'active' : ''}
|
disabled={!effect}
|
||||||
key={pageTiers.join('-')}
|
key={level}
|
||||||
onClick={() => setTalentPage(index)}
|
onClick={() => effect && setSelectedTalentId(effect.id)}
|
||||||
role="tab"
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Tiers {pageTiers[0]}-{pageTiers[pageTiers.length - 1]}
|
<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>
|
</button>
|
||||||
))}
|
)
|
||||||
</nav>
|
})}
|
||||||
|
</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>
|
||||||
|
{selectedTalent ? (
|
||||||
|
<>
|
||||||
|
<strong>{selectedTalent.name}</strong>
|
||||||
|
<small>{selectedTalent.description}</small>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<small>No effect selected.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedTalent && (
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
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 reason = lockReason(talent)
|
||||||
|
const active = talent.rank > 0
|
||||||
|
const selected = selectedTalent?.id === talent.id
|
||||||
const isBusy = busyTalentId === talent.id
|
const isBusy = busyTalentId === talent.id
|
||||||
return (
|
return (
|
||||||
<article
|
<button
|
||||||
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`}
|
className={`${active ? 'active' : ''} ${selected ? 'selected' : ''}`}
|
||||||
|
disabled={Boolean(reason) || isBusy}
|
||||||
key={talent.id}
|
key={talent.id}
|
||||||
style={{ gridColumn: talent.branch }}
|
onClick={() => {
|
||||||
|
setSelectedTalentId(talent.id)
|
||||||
|
void toggleEffect(talent)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="talent-node-header">
|
|
||||||
<span>{talent.glyph}</span>
|
<span>{talent.glyph}</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{talent.name}</strong>
|
<strong>{talent.name}</strong>
|
||||||
<small>Rank {talent.rank}/{talent.maxRank}</small>
|
<small>{EFFECT_SOURCE_LABELS[effectSource(talent.effectType)] ?? 'Spell'}</small>
|
||||||
</div>
|
</div>
|
||||||
|
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p>{talent.description}</p>
|
{effectPageCount > 1 && (
|
||||||
<div className="rank-pips">
|
<div className="effect-pager">
|
||||||
{Array.from({ length: talent.maxRank }, (_, index) => (
|
|
||||||
<i className={index < talent.rank ? 'filled' : ''} key={index} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
disabled={Boolean(reason) || isBusy}
|
disabled={effectPage === 0}
|
||||||
onClick={() => purchaseRank(talent)}
|
onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{isBusy ? 'Saving...' : reason || 'Add Rank'}
|
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>
|
</button>
|
||||||
</article>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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_
|
||||||
}
|
}
|
||||||
@@ -329,10 +364,13 @@ function updateCraftingRecipes(profile: CharacterProfile) {
|
|||||||
...component,
|
...component,
|
||||||
owned: owned.get(component.item.id) ?? 0,
|
owned: owned.get(component.item.id) ?? 0,
|
||||||
}))
|
}))
|
||||||
|
const hasRequiredComponents = components.length > 0
|
||||||
|
&& components.every((component) => component.quantity > 0)
|
||||||
return {
|
return {
|
||||||
...recipe,
|
...recipe,
|
||||||
components,
|
components,
|
||||||
canCraft: components.every((component) => component.owned >= component.quantity),
|
canCraft: hasRequiredComponents
|
||||||
|
&& components.every((component) => component.owned >= component.quantity),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -351,15 +389,58 @@ function addInventoryItem(inventory: Item[], item: Omit<Item, 'quantity' | 'equi
|
|||||||
return { duplicate: false, quantityAfter: quantity }
|
return { duplicate: false, quantityAfter: quantity }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CraftingRecipe = CharacterProfile['craftingRecipes'][number]
|
||||||
|
|
||||||
|
function selectUpgradeRecipe(
|
||||||
|
paths: CharacterProfile['gearUpgradePaths'],
|
||||||
|
recipes: CraftingRecipe[],
|
||||||
|
item: Pick<Item, 'id' | 'slot' | 'itemLevel'>,
|
||||||
|
) {
|
||||||
|
const path = paths.find((candidate) => candidate.fromItemId === item.id)
|
||||||
|
if (path) {
|
||||||
|
const pathRecipe = recipes.find((recipe) => recipe.item.id === path.toItemId)
|
||||||
|
if (pathRecipe) return pathRecipe
|
||||||
|
}
|
||||||
|
const candidates = recipes.filter((recipe) =>
|
||||||
|
recipe.item.slot === item.slot
|
||||||
|
&& recipe.item.itemLevel > item.itemLevel
|
||||||
|
)
|
||||||
|
const nextItemLevel = Math.min(...candidates.map((recipe) => recipe.item.itemLevel))
|
||||||
|
if (!Number.isFinite(nextItemLevel)) return undefined
|
||||||
|
return candidates.find((recipe) => recipe.item.itemLevel === nextItemLevel)
|
||||||
|
}
|
||||||
|
|
||||||
function experienceForLevel(level: number) {
|
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 +451,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 +460,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 +494,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 +513,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 +523,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 +541,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 +696,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 +716,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 +793,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 +809,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 +818,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 +841,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 +852,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,
|
||||||
@@ -716,8 +901,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
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.')
|
||||||
@@ -740,7 +924,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
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 +950,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 +993,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 +1010,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 +1076,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 +1116,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 +1139,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 +1152,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 +1222,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
|
||||||
}
|
}
|
||||||
|
if (save.activeClassId !== 1) {
|
||||||
cd.talentPoints = Math.min(
|
cd.talentPoints = Math.min(
|
||||||
profile.maxTalentPoints,
|
profile.maxTalentPoints,
|
||||||
cd.talentPoints + refunded,
|
cd.talentPoints + refunded,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
store.writeSave(save)
|
store.writeSave(save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
@@ -1064,12 +1302,16 @@ 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.')
|
||||||
|
if (recipe.components.length === 0) throw new Error('That recipe has no component requirements.')
|
||||||
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.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const component of recipe.components) {
|
for (const component of recipe.components) {
|
||||||
|
if (component.quantity <= 0) throw new Error('Recipe components must require at least one item.')
|
||||||
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
|
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
|
||||||
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to craft this item.`)
|
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to craft this item.`)
|
||||||
owned.quantity -= component.quantity
|
owned.quantity -= component.quantity
|
||||||
@@ -1083,6 +1325,49 @@ 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
|
||||||
|
? selectUpgradeRecipe(profile.gearUpgradePaths ?? [], profile.craftingRecipes, item)
|
||||||
|
: null
|
||||||
|
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
|
||||||
|
if (targetRecipe.components.length === 0) throw new Error('That upgrade has no component requirements.')
|
||||||
|
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) {
|
||||||
|
if (component.quantity <= 0) throw new Error('Upgrade components must require at least one item.')
|
||||||
|
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 +1393,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 +1490,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 +1505,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 +1522,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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -134,6 +134,11 @@ export type CraftingRecipe = {
|
|||||||
canCraft: boolean
|
canCraft: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GearUpgradePath = {
|
||||||
|
fromItemId: number
|
||||||
|
toItemId: number
|
||||||
|
}
|
||||||
|
|
||||||
export type LootRollItem = Omit<Item, 'quantity' | 'equipped'> & {
|
export type LootRollItem = Omit<Item, 'quantity' | 'equipped'> & {
|
||||||
quantity: number
|
quantity: number
|
||||||
duplicate: boolean
|
duplicate: boolean
|
||||||
@@ -163,11 +168,13 @@ export type Dungeon = {
|
|||||||
partySize: number
|
partySize: number
|
||||||
completionItemLevel: number | null
|
completionItemLevel: number | null
|
||||||
experienceReward: number
|
experienceReward: number
|
||||||
|
imageUrl: string
|
||||||
description: string
|
description: string
|
||||||
locationName: string
|
locationName: string
|
||||||
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[]
|
||||||
@@ -223,6 +230,7 @@ export type CharacterProfile = {
|
|||||||
}
|
}
|
||||||
setBonuses: SetBonus[]
|
setBonuses: SetBonus[]
|
||||||
craftingRecipes: CraftingRecipe[]
|
craftingRecipes: CraftingRecipe[]
|
||||||
|
gearUpgradePaths: GearUpgradePath[]
|
||||||
dungeons: Dungeon[]
|
dungeons: Dungeon[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +242,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 +256,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 +327,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 +337,7 @@ export async function completeDungeon(
|
|||||||
completedPart,
|
completedPart,
|
||||||
startPart,
|
startPart,
|
||||||
partDurationSeconds,
|
partDurationSeconds,
|
||||||
|
hardMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +350,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 +388,10 @@ export async function craftItem(recipeId: number): Promise<CharacterProfile> {
|
|||||||
return activeGameRepository().craftItem(recipeId)
|
return activeGameRepository().craftItem(recipeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function upgradeItem(itemId: number): Promise<CharacterProfile> {
|
||||||
|
return activeGameRepository().upgradeItem(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
export async function rollEncounterLoot(
|
export async function rollEncounterLoot(
|
||||||
encounterId: number,
|
encounterId: number,
|
||||||
difficultyId: number,
|
difficultyId: number,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type CpuPvpLeaderboardEntry = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
|
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
|
||||||
|
const checkpointKey = 'chronicle.pvpRoguelikeCheckpoint.v1'
|
||||||
|
|
||||||
export function randomCpuDifficulty(): CpuDifficulty {
|
export function randomCpuDifficulty(): CpuDifficulty {
|
||||||
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
|
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
|
||||||
@@ -44,3 +45,24 @@ export function recordCpuPvpLeaderboard(entry: CpuPvpLeaderboardEntry) {
|
|||||||
.slice(0, 30)
|
.slice(0, 30)
|
||||||
localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next))
|
localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkpointStorageKey(characterId: number, contentType: PvpContentType) {
|
||||||
|
return `${checkpointKey}:${characterId}:${contentType}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPvpRoguelikeCheckpoint(characterId: number, contentType: PvpContentType) {
|
||||||
|
const value = Number(localStorage.getItem(checkpointStorageKey(characterId, contentType)) ?? 1)
|
||||||
|
return Number.isInteger(value) && value >= 5 ? value : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordPvpRoguelikeCheckpoint(
|
||||||
|
characterId: number,
|
||||||
|
contentType: PvpContentType,
|
||||||
|
stage: number,
|
||||||
|
) {
|
||||||
|
if (stage < 5 || stage % 5 !== 0) return loadPvpRoguelikeCheckpoint(characterId, contentType)
|
||||||
|
const current = loadPvpRoguelikeCheckpoint(characterId, contentType)
|
||||||
|
const next = Math.max(current, stage)
|
||||||
|
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"files": ["vite.config.ts"]
|
||||||
|
}
|
||||||