Compare commits

..

25 Commits

Author SHA1 Message Date
Warren H c1e2c6d8b5 Android build v1.0.50 2026-06-21 00:27:07 -04:00
Warren H f7b041f86f Android build v1.0.49 2026-06-21 00:13:06 -04:00
Warren H 05bd70a9fe Android build v1.0.47 2026-06-20 23:49:20 -04:00
Warren H bb5c7e6e21 Android build v1.0.46 2026-06-20 23:45:21 -04:00
Warren H 14bec979e6 Android build v1.0.45 2026-06-20 23:13:55 -04:00
Warren H 4b45483ac3 Android build v1.0.44 2026-06-20 23:04:39 -04:00
Warren H 6e10b37f8e Android build v1.0.43 2026-06-20 22:34:38 -04:00
Warren H 5aac39c6c9 Android build v1.0.42 2026-06-20 20:49:46 -04:00
Warren H 224249e372 Android build v1.0.41 2026-06-20 20:42:10 -04:00
Warren H 66f5af4484 Android build v1.0.40 2026-06-20 20:16:20 -04:00
Warren H 8f5a957963 Android build v1.0.39 2026-06-20 20:09:57 -04:00
Warren H f8a1fbc5e2 Android build v1.0.38 2026-06-20 18:06:39 -04:00
Warren H bab2dce6c3 Android build v1.0.37 2026-06-20 17:52:12 -04:00
Warren H cb38042eca Android build v1.0.37 2026-06-20 17:50:57 -04:00
Warren H 753bba581a Android build v1.0.36 2026-06-20 16:57:03 -04:00
Warren H 2300973164 Android build v1.0.35 2026-06-20 16:10:35 -04:00
Warren H 1281be69d8 Android build v1.0.34 2026-06-20 15:08:51 -04:00
Warren H 4fc15ebe9a Android build v1.0.33 2026-06-20 13:21:49 -04:00
Warren H 7313c968e6 Android build v1.0.32 2026-06-20 12:50:48 -04:00
Warren H 207fcd1a15 Android build v1.0.31 2026-06-19 23:14:56 -04:00
Warren H fd6a1ce3c7 Android build v1.0.30 2026-06-19 22:10:14 -04:00
Warren H f8b98e6b23 Android build v1.0.29 2026-06-19 21:58:23 -04:00
Warren H fc7c6488ea Android build v1.0.28 2026-06-19 21:35:17 -04:00
Warren H ba6d3b614e Android build v1.0.27 2026-06-19 21:29:44 -04:00
Warren H 88874933c3 Android build v1.0.26 2026-06-19 20:55:23 -04:00
38 changed files with 12008 additions and 3747 deletions
+20
View File
@@ -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.
+3
View File
@@ -2,5 +2,8 @@
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz. - AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz. - AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
- User rebuilds app; do not rebuild APK unless explicitly requested. - User rebuilds app; do not rebuild APK unless explicitly requested.
- Apply game changes to both web version and mobile app version. - Apply game changes to both web version and mobile app version.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 43 versionCode 69
versionName "1.0.27" versionName "1.0.50"
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+674
View File
@@ -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;
+7
View File
@@ -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,
+619 -54
View File
@@ -106,8 +106,7 @@ SET slug = 'rathian',
image_url = COALESCE(NULLIF(image_url, ''), '/boss-placeholder.svg') image_url = COALESCE(NULLIF(image_url, ''), '/boss-placeholder.svg')
WHERE id = 22; WHERE id = 22;
INSERT OR IGNORE INTO mechanics WITH mechanics_seed(id, encounter_id, name, mechanic_type, interval_seconds, power, description) AS (
(id, encounter_id, name, mechanic_type, interval_seconds, power, description)
VALUES VALUES
(1, 3, 'Cinder Pulse', 'party_damage', 4.9, 12, 'Deals damage to the full party.'), (1, 3, 'Cinder Pulse', 'party_damage', 4.9, 12, 'Deals damage to the full party.'),
(2, 3, 'Searing Mark', 'dispellable_dot', 7.7, 7, 'Marks one target with recurring damage until cleansed.'), (2, 3, 'Searing Mark', 'dispellable_dot', 7.7, 7, 'Marks one target with recurring damage until cleansed.'),
@@ -120,7 +119,17 @@ VALUES
(102, 105, 'Pillar of Judgment', 'party_damage', 5.2, 16, 'Columns of flame erupt beneath the raid.'), (102, 105, 'Pillar of Judgment', 'party_damage', 5.2, 16, 'Columns of flame erupt beneath the raid.'),
(103, 105, 'Inquisitor''s Brand', 'dispellable_dot', 7.8, 10, 'A burning brand persists until cleansed.'), (103, 105, 'Inquisitor''s Brand', 'dispellable_dot', 7.8, 10, 'A burning brand persists until cleansed.'),
(104, 108, 'Crownflare', 'party_damage', 4.9, 18, 'The Ember Crown releases a raid-wide flare.'), (104, 108, 'Crownflare', 'party_damage', 4.9, 18, 'The Ember Crown releases a raid-wide flare.'),
(105, 108, 'Royal Decree', 'dispellable_dot', 7.1, 12, 'A lethal decree marks one raider for cleansing.'); (105, 108, 'Royal Decree', 'dispellable_dot', 7.1, 12, 'A lethal decree marks one raider for cleansing.')
)
INSERT OR IGNORE INTO mechanics
(id, encounter_id, name, mechanic_type, interval_seconds, power, description)
SELECT id, encounter_id, name, mechanic_type, interval_seconds, power, description
FROM mechanics_seed
WHERE EXISTS (
SELECT 1
FROM encounters
WHERE encounters.id = mechanics_seed.encounter_id
);
INSERT OR IGNORE INTO classes INSERT OR IGNORE INTO classes
(id, slug, name, resource_name, max_resource, theme_color, description) (id, slug, name, resource_name, max_resource, theme_color, description)
@@ -134,22 +143,22 @@ INSERT OR IGNORE INTO spells
VALUES VALUES
(1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'), (1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'),
(2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'), (2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'),
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to every living party member.'), (3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to up to 4 injured party members.'),
(4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'), (4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'),
(5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'), (5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'),
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for the entire party.'), (6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for up to 4 injured allies.'),
(7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'), (7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'),
(8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'), (8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'),
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods the party with the full strength of dawn.'), (9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods up to 4 injured allies with the full strength of dawn.'),
(20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'), (20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'),
(21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'), (21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'),
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads through the party.'), (22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads to up to 4 injured allies.'),
(23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'), (23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'),
(24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'), (24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'),
(25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'), (25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'),
(30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'), (30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'),
(31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'), (31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'),
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links the party through a shared healing pattern.'), (32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links up to 4 injured allies through a shared healing pattern.'),
(33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'), (33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'),
(34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'), (34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'),
(35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.'); (35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.');
@@ -191,6 +200,76 @@ UPDATE spells SET unlock_level = 10 WHERE slug = 'guardian-light';
UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun'; UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun';
UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak'; UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak';
UPDATE spells SET
name = 'Verdant Touch',
spell_type = 'direct_hot',
resource_cost = 5,
cooldown_seconds = 0.5,
power = 20,
glyph = '+',
description = 'A weaker direct heal that also plants a stacking heal over time.'
WHERE slug = 'verdant-touch';
UPDATE spells SET
name = 'Wild Growth',
spell_type = 'party_hot',
resource_cost = 12,
cooldown_seconds = 8,
power = 14,
glyph = '*',
description = 'Applies a stacking heal over time to up to 4 injured allies.'
WHERE slug = 'wild-bloom';
UPDATE spells SET
name = 'Barkskin',
spell_type = 'damage_reduction',
resource_cost = 10,
cooldown_seconds = 14,
power = 0,
glyph = 'B',
description = 'Reduces the target ally''s damage taken by 50% for 8 seconds.'
WHERE slug = 'barkskin';
UPDATE spells SET
name = 'Ancient Grove',
spell_type = 'party_hot',
resource_cost = 17,
cooldown_seconds = 12,
power = 24,
glyph = 'T',
description = 'Applies a stronger stacking heal over time to up to 4 injured allies.'
WHERE slug = 'ancient-grove';
UPDATE spells SET
name = 'Mending Rune',
spell_type = 'bounce_heal',
resource_cost = 7,
cooldown_seconds = 0.5,
power = 18,
glyph = 'e',
description = 'Places a rune that heals when the ally takes damage, then jumps 4 times.'
WHERE slug = 'echo-rune';
UPDATE spells SET
name = 'Concordance',
spell_type = 'party_absorb',
resource_cost = 12,
cooldown_seconds = 8,
power = 28,
glyph = '*',
description = 'Shields up to 4 injured allies through a shared barrier pattern.'
WHERE slug = 'concordance';
UPDATE spells SET
name = 'Grand Design',
spell_type = 'party_absorb',
resource_cost = 16,
cooldown_seconds = 12,
power = 42,
glyph = 'R',
description = 'Raises a stronger shared barrier around up to 4 injured allies.'
WHERE slug = 'grand-design';
INSERT OR IGNORE INTO items INSERT OR IGNORE INTO items
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description) (id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
VALUES VALUES
@@ -322,6 +401,16 @@ UPDATE items SET glyph = '/' WHERE slug = 'ashwood-crook';
UPDATE items SET glyph = 'b' WHERE slug = 'cinderstep-boots'; UPDATE items SET glyph = 'b' WHERE slug = 'cinderstep-boots';
UPDATE items SET glyph = '^' WHERE slug = 'adepts-hood'; UPDATE items SET glyph = '^' WHERE slug = 'adepts-hood';
INSERT OR IGNORE INTO encounters
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
VALUES
(3, 1, 9003, 'legacy-loot-encounter-3', 'Legacy Loot Encounter 3', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
(12, 1, 9012, 'legacy-loot-encounter-12', 'Legacy Loot Encounter 12', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
(22, 1, 9022, 'legacy-loot-encounter-22', 'Legacy Loot Encounter 22', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
(102, 2, 9102, 'legacy-loot-encounter-102', 'Legacy Loot Encounter 102', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
(105, 2, 9105, 'legacy-loot-encounter-105', 'Legacy Loot Encounter 105', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
(108, 2, 9108, 'legacy-loot-encounter-108', 'Legacy Loot Encounter 108', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.');
DELETE FROM encounter_loot; DELETE FROM encounter_loot;
INSERT INTO encounter_loot INSERT INTO encounter_loot
@@ -446,20 +535,20 @@ WHERE character_id IN (1, 2, 3)
-- Coin gearing override: every boss/difficulty drops one boss coin, and each -- Coin gearing override: every boss/difficulty drops one boss coin, and each
-- craft costs the target item level in that source boss coin. -- craft costs the target item level in that source boss coin.
UPDATE crafting_recipes UPDATE crafting_recipes
SET source_dungeon_id = 1, SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 3) THEN 1 ELSE NULL END,
source_encounter_id = 3 source_encounter_id = (SELECT id FROM encounters WHERE id = 3)
WHERE id BETWEEN 1001 AND 1409 WHERE id BETWEEN 1001 AND 1409
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves')); AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
UPDATE crafting_recipes UPDATE crafting_recipes
SET source_dungeon_id = 1, SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 12) THEN 1 ELSE NULL END,
source_encounter_id = 12 source_encounter_id = (SELECT id FROM encounters WHERE id = 12)
WHERE id BETWEEN 1001 AND 1409 WHERE id BETWEEN 1001 AND 1409
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket')); AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
UPDATE crafting_recipes UPDATE crafting_recipes
SET source_dungeon_id = 1, SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 22) THEN 1 ELSE NULL END,
source_encounter_id = 22 source_encounter_id = (SELECT id FROM encounters WHERE id = 22)
WHERE id BETWEEN 1001 AND 1409 WHERE id BETWEEN 1001 AND 1409
AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace')); AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace'));
@@ -479,9 +568,7 @@ WHERE id BETWEEN 901 AND 1409;
UPDATE items UPDATE items
SET rarity = CASE item_level SET rarity = CASE item_level
WHEN 1 THEN 'common' WHEN 1 THEN 'common'
WHEN 5 THEN 'common'
WHEN 10 THEN 'uncommon' WHEN 10 THEN 'uncommon'
WHEN 15 THEN 'rare'
WHEN 20 THEN 'epic' WHEN 20 THEN 'epic'
WHEN 25 THEN 'legendary' WHEN 25 THEN 'legendary'
ELSE rarity ELSE rarity
@@ -489,7 +576,7 @@ SET rarity = CASE item_level
WHERE id IN (SELECT item_id FROM crafting_recipes); WHERE id IN (SELECT item_id FROM crafting_recipes);
UPDATE items UPDATE items
SET name = ( SET name = COALESCE((
SELECT SELECT
CASE items.item_level CASE items.item_level
WHEN 1 THEN 'Raw ' WHEN 1 THEN 'Raw '
@@ -517,14 +604,14 @@ SET name = (
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
WHERE crafting_recipes.item_id = items.id WHERE crafting_recipes.item_id = items.id
LIMIT 1 LIMIT 1
), ), name),
description = ( description = COALESCE((
SELECT 'Crafted with ' || encounters.name || ' coins.' SELECT 'Crafted with ' || encounters.name || ' coins.'
FROM crafting_recipes FROM crafting_recipes
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
WHERE crafting_recipes.item_id = items.id WHERE crafting_recipes.item_id = items.id
LIMIT 1 LIMIT 1
) ), description)
WHERE id IN (SELECT item_id FROM crafting_recipes); WHERE id IN (SELECT item_id FROM crafting_recipes);
CREATE TEMP TABLE IF NOT EXISTS coin_sources ( CREATE TEMP TABLE IF NOT EXISTS coin_sources (
@@ -543,11 +630,11 @@ DELETE FROM coin_sources;
INSERT INTO coin_sources INSERT INTO coin_sources
SELECT SELECT
280000 + encounters.id * 100 + difficulties.dropped_item_level, 280000 + encounters.id * 1000 + difficulties.id,
encounters.id, encounters.id,
difficulties.id, difficulties.id,
difficulties.dropped_item_level, difficulties.dropped_item_level,
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level, encounters.slug || '-coin-diff-' || difficulties.id || '-ilvl-' || difficulties.dropped_item_level,
CASE difficulties.dropped_item_level CASE difficulties.dropped_item_level
WHEN 1 THEN 'Raw ' WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed ' WHEN 5 THEN 'Honed '
@@ -579,6 +666,18 @@ INSERT OR IGNORE INTO items
SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description
FROM coin_sources; FROM coin_sources;
UPDATE coin_sources
SET item_id = (
SELECT items.id
FROM items
WHERE items.slug = coin_sources.slug
)
WHERE EXISTS (
SELECT 1
FROM items
WHERE items.slug = coin_sources.slug
);
UPDATE items UPDATE items
SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id), SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id),
name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id), name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id),
@@ -610,18 +709,23 @@ JOIN coin_sources
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id; AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
DELETE FROM character_talents
WHERE talent_id IN (SELECT id FROM talents WHERE class_id = 1);
DELETE FROM talents WHERE class_id = 1;
INSERT OR IGNORE INTO talents INSERT OR IGNORE INTO talents
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description) (id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
VALUES VALUES
(1, 1, 'bright-reserves', 'Bright Reserves', 5, 1, 1, NULL, 0, 'max_resource', 2, 'M', 'Increases maximum Mana by 2 per rank.'), (1, 1, 'shield-applies-renew', 'Shield applies Renew', 1, 1, 1, NULL, 0, 'shield_applies_renew', 0, '~', 'Sun Ward also applies Renew to the target.'),
(2, 1, 'gentle-dawn', 'Gentle Dawn', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, '~', 'Increases healing-over-time power by 2% per rank.'), (2, 1, 'mend-applies-renew', 'Mend applies Renew', 1, 1, 2, NULL, 0, 'mend_applies_renew', 0, '~', 'Mend also applies Renew to the target.'),
(10, 1, 'steady-hands', 'Steady Hands', 5, 1, 3, NULL, 0, 'direct_heal_percent', 2, '+', 'Increases direct healing by 2% per rank.'), (10, 1, 'mend-adds-shield', 'Mend adds Shield', 1, 1, 3, NULL, 0, 'mend_applies_shield', 0, 'O', 'Mend also applies a shield at 50% strength to the target.'),
(11, 1, 'overflowing-light', 'Overflowing Light', 5, 2, 1, 1, 3, 'resource_regen_percent', 2, 'O', 'Improves Mana regeneration by 2% per rank. Requires Bright Reserves rank 3.'), (11, 1, 'radiance-adds-shield', 'Radiance adds Shield', 1, 1, 4, NULL, 0, 'radiance_applies_shield', 0, 'O', 'Radiance applies a shield at 30% strength to affected party members.'),
(12, 1, 'lingering-rays', 'Lingering Rays', 5, 2, 2, 2, 3, 'hot_duration_percent', 4, 'L', 'Extends healing-over-time duration by 4% per rank. Requires Gentle Dawn rank 3.'), (12, 1, 'radiance-applies-renew', 'Radiance applies Renew', 1, 1, 5, NULL, 0, 'radiance_applies_renew', 0, '~', 'Radiance applies Renew at 50% duration to affected party members.'),
(13, 1, 'radiant-precision', 'Radiant Precision', 5, 2, 3, 10, 3, 'critical_heal_percent', 1, '!', 'Adds 1% healing critical chance per rank. Requires Steady Hands rank 3.'), (13, 1, 'shielded-damage-reduction', 'Shielded takes less', 1, 1, 6, NULL, 0, 'shielded_damage_reduction', 0, 'D', 'While shielded, the target receives 20% less damage.'),
(14, 1, 'sunlit-aegis', 'Sunlit Aegis', 3, 3, 1, 11, 5, 'absorb_power_percent', 5, 'A', 'Strengthens absorb effects by 5% per rank. Requires Overflowing Light rank 5.'), (14, 1, 'shielded-healing-bonus', 'Shielded healing boost', 1, 1, 7, NULL, 0, 'shielded_healing_bonus', 0, '+', 'While shielded, the target receives 20% more healing.'),
(15, 1, 'shared-dawn', 'Shared Dawn', 3, 3, 2, 12, 5, 'party_heal_percent', 5, '*', 'Increases party-wide healing by 5% per rank. Requires Lingering Rays rank 5.'), (15, 1, 'mend-reduces-radiance', 'Mend lowers Radiance', 1, 1, 8, NULL, 0, 'mend_reduces_radiance_cooldown', 0, '*', 'Casting Mend reduces the cooldown of Radiance by 2 seconds.'),
(16, 1, 'miracle-worker', 'Miracle Worker', 1, 4, 2, 15, 3, 'cooldown_reduction_percent', 10, 'S', 'Reduces major healing cooldowns by 10%. Requires Shared Dawn rank 3.'),
(3, 2, 'deep-roots', 'Deep Roots', 5, 1, 1, NULL, 0, 'max_resource', 2, 'R', 'Increases maximum Bloom by 2 per rank.'), (3, 2, 'deep-roots', 'Deep Roots', 5, 1, 1, NULL, 0, 'max_resource', 2, 'R', 'Increases maximum Bloom by 2 per rank.'),
(20, 2, 'patient-growth', 'Patient Growth', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, 's', 'Increases healing-over-time power by 2% per rank.'), (20, 2, 'patient-growth', 'Patient Growth', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, 's', 'Increases healing-over-time power by 2% per rank.'),
@@ -728,6 +832,7 @@ INSERT INTO generated_loot_tiers
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity) (item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
VALUES VALUES
(10, 3, 2, 2, 101, 1100, 2), (10, 3, 2, 2, 101, 1100, 2),
(15, 4, 5, 3, 103, 1200, 3),
(20, 6, 7, 4, 104, 1300, 4), (20, 6, 7, 4, 104, 1300, 4),
(25, 8, 9, 5, 105, 1400, 5); (25, 8, 9, 5, 105, 1400, 5);
@@ -786,8 +891,8 @@ INSERT OR IGNORE INTO locations (id, slug, name, description) VALUES
(3, 'monster-frontier', 'The Monster Frontier', 'Hunting grounds used for tiered monster-part progression.'); (3, 'monster-frontier', 'The Monster Frontier', 'Hunting grounds used for tiered monster-part progression.');
UPDATE dungeons UPDATE dungeons
SET slug = 'tigrex-raid', SET slug = 'legacy-generated-raid-2',
name = 'Tigrex Raid', name = 'Legacy Generated Raid 2',
location_id = 3, location_id = 3,
recommended_level = 5, recommended_level = 5,
content_type = 'raid', content_type = 'raid',
@@ -870,6 +975,68 @@ SELECT dungeon_id, dungeon_difficulty_id FROM generated_loot_tiers
UNION ALL UNION ALL
SELECT raid_id, raid_difficulty_id FROM generated_loot_tiers; SELECT raid_id, raid_difficulty_id FROM generated_loot_tiers;
UPDATE crafting_recipes
SET source_dungeon_id = NULL,
source_encounter_id = NULL
WHERE source_dungeon_id IN (
SELECT dungeon_id FROM generated_loot_tiers
UNION
SELECT raid_id FROM generated_loot_tiers
)
OR source_encounter_id IN (
SELECT id
FROM encounters
WHERE dungeon_id IN (
SELECT dungeon_id FROM generated_loot_tiers
UNION
SELECT raid_id FROM generated_loot_tiers
)
);
DELETE FROM encounter_loot_roll_items
WHERE roll_id IN (
SELECT id
FROM encounter_loot_rolls
WHERE encounter_id IN (
SELECT id
FROM encounters
WHERE dungeon_id IN (
SELECT dungeon_id FROM generated_loot_tiers
UNION
SELECT raid_id FROM generated_loot_tiers
)
)
);
DELETE FROM encounter_loot_rolls
WHERE encounter_id IN (
SELECT id
FROM encounters
WHERE dungeon_id IN (
SELECT dungeon_id FROM generated_loot_tiers
UNION
SELECT raid_id FROM generated_loot_tiers
)
);
DELETE FROM encounter_loot
WHERE encounter_id IN (
SELECT id
FROM encounters
WHERE dungeon_id IN (
SELECT dungeon_id FROM generated_loot_tiers
UNION
SELECT raid_id FROM generated_loot_tiers
)
);
DELETE FROM encounters
WHERE dungeon_id IN (
SELECT dungeon_id FROM generated_loot_tiers
UNION
SELECT raid_id FROM generated_loot_tiers
);
UPDATE encounters UPDATE encounters
SET slug = CASE id SET slug = CASE id
WHEN 100 THEN 'tigrex-raid-approach' WHEN 100 THEN 'tigrex-raid-approach'
@@ -902,7 +1069,23 @@ SET slug = CASE id
WHEN 108 THEN 'Gypceros drops boss coins for item level 10 crafting.' WHEN 108 THEN 'Gypceros drops boss coins for item level 10 crafting.'
ELSE 'Hunters clear the raid path.' ELSE 'Hunters clear the raid path.'
END END
WHERE id BETWEEN 100 AND 108; WHERE id BETWEEN 100 AND 108
AND NOT EXISTS (
SELECT 1
FROM encounters AS conflict
WHERE conflict.id NOT BETWEEN 100 AND 108
AND conflict.slug IN (
'tigrex-raid-approach',
'tigrex-raid-guardians',
'tigrex-raid',
'rathalos-raid-approach',
'rathalos-raid-guardians',
'rathalos-raid',
'gypceros-raid-approach',
'gypceros-raid-guardians',
'gypceros-raid'
)
);
INSERT OR IGNORE INTO encounters INSERT OR IGNORE INTO encounters
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description) (id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
@@ -990,7 +1173,12 @@ SELECT
1.0 1.0
FROM generated_loot_tiers FROM generated_loot_tiers
JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level
JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index; JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index
WHERE EXISTS (
SELECT 1
FROM encounters
WHERE encounters.id = generated_loot_tiers.dungeon_id * 100 + generated_bosses.boss_index * 3 + 3
);
INSERT OR IGNORE INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) INSERT OR IGNORE INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
SELECT SELECT
@@ -1004,7 +1192,15 @@ SELECT
1.0 1.0
FROM generated_loot_tiers FROM generated_loot_tiers
JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level
JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index; JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index
WHERE EXISTS (
SELECT 1
FROM encounters
WHERE encounters.id = CASE generated_loot_tiers.raid_id
WHEN 2 THEN 102 + generated_bosses.boss_index * 3
ELSE generated_loot_tiers.raid_id * 100 + generated_bosses.boss_index * 3 + 3
END
);
CREATE TEMP TABLE IF NOT EXISTS generated_recipe_offsets ( CREATE TEMP TABLE IF NOT EXISTS generated_recipe_offsets (
recipe_offset INTEGER PRIMARY KEY, recipe_offset INTEGER PRIMARY KEY,
@@ -1032,12 +1228,8 @@ SET source_dungeon_id = (
WHERE id BETWEEN 1101 AND 1409; WHERE id BETWEEN 1101 AND 1409;
UPDATE crafting_recipes UPDATE crafting_recipes
SET source_dungeon_id = 2, SET source_dungeon_id = NULL,
source_encounter_id = 102 + ( source_encounter_id = NULL
SELECT generated_recipe_offsets.boss_index * 3
FROM generated_recipe_offsets
WHERE crafting_recipes.id = 2000 + generated_recipe_offsets.recipe_offset
)
WHERE id BETWEEN 2001 AND 2009; WHERE id BETWEEN 2001 AND 2009;
DELETE FROM crafting_recipe_components DELETE FROM crafting_recipe_components
@@ -1246,20 +1438,20 @@ INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity)
-- Final coin gearing override. Keep this after legacy loot edits. -- Final coin gearing override. Keep this after legacy loot edits.
UPDATE crafting_recipes UPDATE crafting_recipes
SET source_dungeon_id = 1, SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 3) THEN 1 ELSE NULL END,
source_encounter_id = 3 source_encounter_id = (SELECT id FROM encounters WHERE id = 3)
WHERE id BETWEEN 1001 AND 1409 WHERE id BETWEEN 1001 AND 1409
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves')); AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
UPDATE crafting_recipes UPDATE crafting_recipes
SET source_dungeon_id = 1, SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 12) THEN 1 ELSE NULL END,
source_encounter_id = 12 source_encounter_id = (SELECT id FROM encounters WHERE id = 12)
WHERE id BETWEEN 1001 AND 1409 WHERE id BETWEEN 1001 AND 1409
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket')); AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
UPDATE crafting_recipes UPDATE crafting_recipes
SET source_dungeon_id = 1, SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 22) THEN 1 ELSE NULL END,
source_encounter_id = 22 source_encounter_id = (SELECT id FROM encounters WHERE id = 22)
WHERE id BETWEEN 1001 AND 1409 WHERE id BETWEEN 1001 AND 1409
AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace')); AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace'));
@@ -1276,10 +1468,23 @@ SET difficulty_id = CASE
END END
WHERE id BETWEEN 901 AND 1409; WHERE id BETWEEN 901 AND 1409;
DELETE FROM crafting_recipe_components
WHERE recipe_id IN (
SELECT crafting_recipes.id
FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id
WHERE items.item_level NOT IN (1, 5, 10, 15, 20, 25)
);
DELETE FROM crafting_recipes
WHERE item_id IN (
SELECT id FROM items WHERE item_level NOT IN (1, 5, 10, 15, 20, 25)
);
UPDATE items UPDATE items
SET rarity = CASE item_level SET rarity = CASE item_level
WHEN 1 THEN 'common' WHEN 1 THEN 'common'
WHEN 5 THEN 'common' WHEN 5 THEN 'uncommon'
WHEN 10 THEN 'uncommon' WHEN 10 THEN 'uncommon'
WHEN 15 THEN 'rare' WHEN 15 THEN 'rare'
WHEN 20 THEN 'epic' WHEN 20 THEN 'epic'
@@ -1289,7 +1494,7 @@ SET rarity = CASE item_level
WHERE id IN (SELECT item_id FROM crafting_recipes); WHERE id IN (SELECT item_id FROM crafting_recipes);
UPDATE items UPDATE items
SET name = ( SET name = COALESCE((
SELECT SELECT
CASE items.item_level CASE items.item_level
WHEN 1 THEN 'Raw ' WHEN 1 THEN 'Raw '
@@ -1317,25 +1522,318 @@ SET name = (
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
WHERE crafting_recipes.item_id = items.id WHERE crafting_recipes.item_id = items.id
LIMIT 1 LIMIT 1
), ), name),
description = ( description = COALESCE((
SELECT 'Crafted with ' || encounters.name || ' coins.' SELECT 'Crafted with ' || encounters.name || ' coins.'
FROM crafting_recipes FROM crafting_recipes
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
WHERE crafting_recipes.item_id = items.id WHERE crafting_recipes.item_id = items.id
LIMIT 1 LIMIT 1
), description)
WHERE id IN (SELECT item_id FROM crafting_recipes);
-- Single-boss hunting grounds. Keep this late so legacy three-boss hunt
-- seed data and generated monster tiers are normalized into separate runs.
CREATE TEMP TABLE IF NOT EXISTS single_boss_dungeons (
dungeon_id INTEGER PRIMARY KEY,
boss_name TEXT NOT NULL,
boss_slug TEXT NOT NULL,
item_level INTEGER NOT NULL,
difficulty_floor INTEGER NOT NULL,
location_id INTEGER NOT NULL,
experience_reward INTEGER NOT NULL
);
DELETE FROM single_boss_dungeons;
INSERT INTO single_boss_dungeons
(dungeon_id, boss_name, boss_slug, item_level, difficulty_floor, location_id, experience_reward)
VALUES
(1, 'Bulldrome', 'bulldrome', 1, 1, 1, 125),
(2, 'Yian Kut-Ku', 'yian-kut-ku', 1, 1, 1, 125),
(3, 'Rathian', 'rathian', 1, 1, 1, 125),
(4, 'Tigrex', 'tigrex', 10, 2, 3, 205),
(5, 'Rathalos', 'rathalos', 10, 2, 3, 205),
(6, 'Gypceros', 'gypceros', 10, 2, 3, 205),
(7, 'Nargacuga', 'nargacuga', 15, 3, 3, 245),
(8, 'Azuros', 'azuros', 15, 3, 3, 245),
(9, 'Diablos', 'diablos', 15, 3, 3, 245),
(10, 'Barroth', 'barroth', 20, 4, 3, 285),
(11, 'Tobi Kadachi', 'tobi-kadachi', 20, 4, 3, 285),
(12, 'Monoblos', 'monoblos', 20, 4, 3, 285),
(13, 'Anjanath', 'anjanath', 25, 5, 3, 325),
(14, 'Bazelgeuse', 'bazelgeuse', 25, 5, 3, 325),
(15, 'Odogaron', 'odogaron', 25, 5, 3, 325);
CREATE TEMP TABLE IF NOT EXISTS single_boss_raids (
raid_id INTEGER PRIMARY KEY,
dungeon_id INTEGER NOT NULL,
difficulty_id INTEGER NOT NULL,
experience_reward INTEGER NOT NULL
);
DELETE FROM single_boss_raids;
INSERT INTO single_boss_raids (raid_id, dungeon_id, difficulty_id, experience_reward)
VALUES
(20, 4, 101, 275),
(21, 5, 101, 275),
(22, 6, 101, 275),
(23, 7, 103, 325),
(24, 8, 103, 325),
(25, 9, 103, 325),
(26, 10, 104, 375),
(27, 11, 104, 375),
(28, 12, 104, 375),
(29, 13, 105, 425),
(30, 14, 105, 425),
(31, 15, 105, 425);
UPDATE dungeons
SET slug = 'retired-dungeon-' || id
WHERE id BETWEEN 1 AND 31;
INSERT OR IGNORE INTO dungeons
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
SELECT
dungeon_id,
location_id,
boss_slug || '-hunting-ground',
boss_name || ' Hunting Ground',
CASE difficulty_floor WHEN 1 THEN 1 ELSE item_level END,
'dungeon',
6,
NULL,
experience_reward,
'A focused hunt through ' || boss_name || ' territory.'
FROM single_boss_dungeons;
UPDATE dungeons
SET location_id = (SELECT location_id FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
slug = (SELECT boss_slug || '-hunting-ground' FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
name = (SELECT boss_name || ' Hunting Ground' FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
recommended_level = (SELECT CASE difficulty_floor WHEN 1 THEN 1 ELSE item_level END FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
content_type = 'dungeon',
party_size = 6,
completion_item_level = NULL,
experience_reward = (SELECT experience_reward FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
description = (SELECT 'A focused hunt through ' || boss_name || ' territory.' FROM single_boss_dungeons WHERE dungeon_id = dungeons.id)
WHERE id IN (SELECT dungeon_id FROM single_boss_dungeons);
INSERT OR IGNORE INTO dungeons
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
SELECT
single_boss_raids.raid_id,
single_boss_dungeons.location_id,
'apex-' || single_boss_dungeons.boss_slug || '-raid',
'Apex ' || single_boss_dungeons.boss_name || ' Raid',
single_boss_dungeons.item_level,
'raid',
18,
NULL,
single_boss_raids.experience_reward,
'A raid-scale hunt against Apex ' || single_boss_dungeons.boss_name || '.'
FROM single_boss_raids
JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id;
UPDATE dungeons
SET slug = (SELECT 'apex-' || single_boss_dungeons.boss_slug || '-raid' FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id),
name = (SELECT 'Apex ' || single_boss_dungeons.boss_name || ' Raid' FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id),
location_id = (SELECT single_boss_dungeons.location_id FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id),
recommended_level = (SELECT single_boss_dungeons.item_level FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id),
content_type = 'raid',
party_size = 18,
completion_item_level = NULL,
experience_reward = (SELECT experience_reward FROM single_boss_raids WHERE single_boss_raids.raid_id = dungeons.id),
description = (SELECT 'A raid-scale hunt against Apex ' || single_boss_dungeons.boss_name || '.' FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id)
WHERE id IN (SELECT raid_id FROM single_boss_raids);
UPDATE crafting_recipes
SET source_dungeon_id = NULL,
source_encounter_id = NULL
WHERE id BETWEEN 1001 AND 1409;
DELETE FROM encounter_loot_roll_items
WHERE roll_id IN (
SELECT id
FROM encounter_loot_rolls
WHERE encounter_id IN (
SELECT id FROM encounters
WHERE dungeon_id IN (SELECT dungeon_id FROM single_boss_dungeons)
OR dungeon_id IN (SELECT raid_id FROM single_boss_raids)
) )
);
DELETE FROM encounter_loot_rolls
WHERE encounter_id IN (
SELECT id FROM encounters
WHERE dungeon_id IN (SELECT dungeon_id FROM single_boss_dungeons)
OR dungeon_id IN (SELECT raid_id FROM single_boss_raids)
);
DELETE FROM encounters WHERE dungeon_id IN (SELECT dungeon_id FROM single_boss_dungeons);
DELETE FROM encounters WHERE dungeon_id IN (SELECT raid_id FROM single_boss_raids);
INSERT INTO encounters
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
SELECT
single_boss_dungeons.dungeon_id * 100 + offset.value,
single_boss_dungeons.dungeon_id,
offset.value,
single_boss_dungeons.boss_slug || '-' || offset.slug,
CASE offset.encounter_type
WHEN 'boss' THEN single_boss_dungeons.boss_name
ELSE single_boss_dungeons.boss_name || ' ' || offset.name
END,
offset.encounter_type,
offset.health + single_boss_dungeons.item_level * 35,
offset.damage + single_boss_dungeons.difficulty_floor,
offset.tank_damage + single_boss_dungeons.difficulty_floor,
offset.party_damage + single_boss_dungeons.difficulty_floor * 2,
CASE offset.encounter_type
WHEN 'boss' THEN single_boss_dungeons.boss_name || ' drops boss coins for crafting.'
ELSE 'Hunters clear the path before ' || single_boss_dungeons.boss_name || '.'
END
FROM single_boss_dungeons
JOIN (
SELECT 1 AS value, 'approach' AS slug, 'Approach' AS name, 'trash' AS encounter_type, 430 AS health, 13 AS damage, 8 AS tank_damage, 24 AS party_damage
UNION ALL SELECT 2, 'guardians', 'Guardians', 'trash', 520, 15, 9, 26
UNION ALL SELECT 3, 'boss', '', 'boss', 860, 19, 14, 29
) AS offset;
INSERT INTO encounters
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
SELECT
single_boss_raids.raid_id * 100 + offset.value,
single_boss_raids.raid_id,
offset.value,
single_boss_dungeons.boss_slug || '-raid-' || offset.slug,
CASE offset.encounter_type
WHEN 'boss' THEN 'Apex ' || single_boss_dungeons.boss_name
ELSE 'Apex ' || single_boss_dungeons.boss_name || ' ' || offset.name
END,
offset.encounter_type,
offset.health + single_boss_dungeons.item_level * 35 + 900,
offset.damage + single_boss_dungeons.difficulty_floor,
offset.tank_damage + single_boss_dungeons.difficulty_floor,
offset.party_damage + single_boss_dungeons.difficulty_floor * 2 + 22,
CASE offset.encounter_type
WHEN 'boss' THEN 'Apex ' || single_boss_dungeons.boss_name || ' drops raid coins for crafting.'
ELSE 'Hunters clear the raid path before Apex ' || single_boss_dungeons.boss_name || '.'
END
FROM single_boss_raids
JOIN single_boss_dungeons
ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id
JOIN (
SELECT 1 AS value, 'approach' AS slug, 'Approach' AS name, 'trash' AS encounter_type, 650 AS health, 15 AS damage, 9 AS tank_damage, 28 AS party_damage
UNION ALL SELECT 2, 'guardians', 'Guardians', 'trash', 720, 16, 10, 30
UNION ALL SELECT 3, 'boss', '', 'boss', 980, 20, 14, 34
) AS offset;
DELETE FROM dungeon_difficulties;
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id)
SELECT dungeon_id, difficulties.id
FROM single_boss_dungeons
JOIN difficulties ON difficulties.id BETWEEN single_boss_dungeons.difficulty_floor AND 5
WHERE difficulties.id BETWEEN 1 AND 5;
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id)
SELECT raid_id, difficulty_id
FROM single_boss_raids;
CREATE TEMP TABLE IF NOT EXISTS recipe_source_override (
recipe_id INTEGER PRIMARY KEY,
dungeon_id INTEGER NOT NULL,
encounter_id INTEGER NOT NULL
);
DELETE FROM recipe_source_override;
INSERT INTO recipe_source_override (recipe_id, dungeon_id, encounter_id) VALUES
(1001, 1, 103), (1002, 1, 103), (1003, 1, 103),
(1004, 2, 203), (1005, 2, 203), (1006, 2, 203),
(1007, 3, 303), (1008, 3, 303), (1009, 3, 303),
(1101, 4, 403), (1102, 4, 403), (1103, 4, 403),
(1104, 5, 503), (1105, 5, 503), (1106, 5, 503),
(1107, 6, 603), (1108, 6, 603), (1109, 6, 603),
(1201, 7, 703), (1202, 7, 703), (1203, 7, 703),
(1204, 8, 803), (1205, 8, 803), (1206, 8, 803),
(1207, 9, 903), (1208, 9, 903), (1209, 9, 903),
(1301, 10, 1003), (1302, 10, 1003), (1303, 10, 1003),
(1304, 11, 1103), (1305, 11, 1103), (1306, 11, 1103),
(1307, 12, 1203), (1308, 12, 1203), (1309, 12, 1203),
(1401, 13, 1303), (1402, 13, 1303), (1403, 13, 1303),
(1404, 14, 1403), (1405, 14, 1403), (1406, 14, 1403),
(1407, 15, 1503), (1408, 15, 1503), (1409, 15, 1503);
UPDATE crafting_recipes
SET source_dungeon_id = (SELECT dungeon_id FROM recipe_source_override WHERE recipe_id = crafting_recipes.id),
source_encounter_id = (SELECT encounter_id FROM recipe_source_override WHERE recipe_id = crafting_recipes.id)
WHERE id IN (SELECT recipe_id FROM recipe_source_override);
UPDATE crafting_recipes
SET source_dungeon_id = (
SELECT single_boss_raids.raid_id
FROM generated_recipe_offsets
JOIN single_boss_raids
ON single_boss_raids.dungeon_id = 4 + generated_recipe_offsets.boss_index
WHERE crafting_recipes.id = 2000 + generated_recipe_offsets.recipe_offset
),
source_encounter_id = (
SELECT single_boss_raids.raid_id * 100 + 3
FROM generated_recipe_offsets
JOIN single_boss_raids
ON single_boss_raids.dungeon_id = 4 + generated_recipe_offsets.boss_index
WHERE crafting_recipes.id = 2000 + generated_recipe_offsets.recipe_offset
),
difficulty_id = 101
WHERE id BETWEEN 2001 AND 2009;
UPDATE items
SET name = COALESCE((
SELECT
CASE items.item_level
WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple '
WHEN 25 THEN 'Orange '
ELSE ''
END
|| encounters.name || ' '
|| CASE items.slot
WHEN 'weapon' THEN 'Weapon'
WHEN 'helmet' THEN 'Helmet'
WHEN 'chest' THEN 'Chest'
WHEN 'gloves' THEN 'Gloves'
WHEN 'boots' THEN 'Boots'
WHEN 'pants' THEN 'Pants'
WHEN 'ring' THEN 'Ring'
WHEN 'necklace' THEN 'Necklace'
WHEN 'trinket' THEN 'Trinket'
ELSE items.name
END
FROM crafting_recipes
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
WHERE crafting_recipes.item_id = items.id
LIMIT 1
), name),
description = COALESCE((
SELECT 'Crafted with ' || encounters.name || ' coins.'
FROM crafting_recipes
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
WHERE crafting_recipes.item_id = items.id
LIMIT 1
), description)
WHERE id IN (SELECT item_id FROM crafting_recipes); WHERE id IN (SELECT item_id FROM crafting_recipes);
DELETE FROM coin_sources; DELETE FROM coin_sources;
INSERT INTO coin_sources INSERT INTO coin_sources
SELECT SELECT
280000 + encounters.id * 100 + difficulties.dropped_item_level, 280000 + encounters.id * 1000 + difficulties.id,
encounters.id, encounters.id,
difficulties.id, difficulties.id,
difficulties.dropped_item_level, difficulties.dropped_item_level,
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level, encounters.slug || '-coin-diff-' || difficulties.id || '-ilvl-' || difficulties.dropped_item_level,
CASE difficulties.dropped_item_level CASE difficulties.dropped_item_level
WHEN 1 THEN 'Raw ' WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed ' WHEN 5 THEN 'Honed '
@@ -1367,6 +1865,18 @@ INSERT OR IGNORE INTO items
SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description
FROM coin_sources; FROM coin_sources;
UPDATE coin_sources
SET item_id = (
SELECT items.id
FROM items
WHERE items.slug = coin_sources.slug
)
WHERE EXISTS (
SELECT 1
FROM items
WHERE items.slug = coin_sources.slug
);
UPDATE items UPDATE items
SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id), SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id),
name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id), name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id),
@@ -1397,3 +1907,58 @@ JOIN items ON items.id = crafting_recipes.item_id
JOIN coin_sources JOIN coin_sources
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id; AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
DELETE FROM crafting_recipe_components
WHERE recipe_id IN (1101, 1102, 1103);
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
SELECT recipe_id, items.id, quantity
FROM (
SELECT 1101 AS recipe_id, 'tigrex-boss-coin-diff-2-ilvl-10' AS item_slug, 5 AS quantity
UNION ALL SELECT 1101, 'bulldrome-boss-coin-diff-2-ilvl-10', 5
UNION ALL SELECT 1102, 'tigrex-boss-coin-diff-2-ilvl-10', 5
UNION ALL SELECT 1102, 'bulldrome-boss-coin-diff-2-ilvl-10', 5
UNION ALL SELECT 1103, 'tigrex-boss-coin-diff-2-ilvl-10', 5
UNION ALL SELECT 1103, 'bulldrome-boss-coin-diff-2-ilvl-10', 5
) AS requirements
JOIN items ON items.slug = requirements.item_slug;
DELETE FROM gear_upgrade_paths;
INSERT INTO gear_upgrade_paths (from_item_id, to_item_id)
WITH gear_items AS (
SELECT
items.id,
items.slot,
items.item_level AS item_level
FROM items
WHERE items.slot <> 'component'
AND items.item_level IN (1, 5, 10, 15, 20, 25)
),
next_levels AS (
SELECT
source.id AS from_item_id,
source.slot,
MIN(target.item_level) AS next_item_level
FROM gear_items AS source
JOIN gear_items AS target
ON target.slot = source.slot
AND target.item_level > source.item_level
WHERE source.item_level < 25
GROUP BY source.id, source.slot
)
SELECT from_item_id, to_item_id
FROM (
SELECT
next_levels.from_item_id,
(
SELECT target.id
FROM gear_items AS target
WHERE target.slot = next_levels.slot
AND target.item_level = next_levels.next_item_level
ORDER BY target.id
LIMIT 1
) AS to_item_id
FROM next_levels
)
WHERE to_item_id IS NOT NULL;
+2 -2
View File
@@ -79,9 +79,9 @@ cd /Users/warren/Documents/testgame/testgame
export GITEA_URL="https://git.whoagland.com" export GITEA_URL="https://git.whoagland.com"
export GITEA_OWNER="phenom" export GITEA_OWNER="phenom"
export GITEA_REPO="i-want-to-heal" export GITEA_REPO="i-want-to-heal"
export GITEA_TOKEN="PASTE_TOKEN_HERE" export GITEA_TOKEN="ed2db3fd54546e9658377d0551b3fc3961583f1d"
VERSION="1.0.26" VERSION="1.0.27"
APK="IWantToHeal-Thor-v$VERSION.apk" APK="IWantToHeal-Thor-v$VERSION.apk"
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \ RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
@@ -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

+7 -2
View File
@@ -1,5 +1,6 @@
import { readFileSync, writeFileSync } from 'node:fs' import { readFileSync, writeFileSync } from 'node:fs'
import { DatabaseSync } from 'node:sqlite' import { DatabaseSync } from 'node:sqlite'
import { catalogPayload } from '../server/catalog.mjs'
import { getProfile } from '../server/game-api.mjs' import { getProfile } from '../server/game-api.mjs'
const database = new DatabaseSync(':memory:') const database = new DatabaseSync(':memory:')
@@ -7,10 +8,14 @@ const database = new DatabaseSync(':memory:')
try { try {
database.exec(readFileSync('db/schema.sql', 'utf8')) database.exec(readFileSync('db/schema.sql', 'utf8'))
database.exec(readFileSync('db/seed.sql', 'utf8')) database.exec(readFileSync('db/seed.sql', 'utf8'))
const profile = getProfile(database, 1) const catalog = catalogPayload(getProfile(database, 1))
writeFileSync( writeFileSync(
'src/offline-starter-profile.json', 'src/offline-starter-profile.json',
`${JSON.stringify(profile, null, 2)}\n`, `${JSON.stringify(catalog.profile, null, 2)}\n`,
)
writeFileSync(
'src/offline-catalog-meta.ts',
`export const bundledCatalogHash = '${catalog.hash}'\n`,
) )
console.log('Offline starter profile exported from SQLite.') console.log('Offline starter profile exported from SQLite.')
} finally { } finally {
+8 -1
View File
@@ -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
+283 -7
View File
@@ -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
} }
+74
View File
@@ -0,0 +1,74 @@
import { createHash } from 'node:crypto'
function normalizeRecipe(recipe) {
return {
...recipe,
components: recipe.components.map((component) => ({
...component,
owned: 0,
})),
canCraft: false,
}
}
function normalizeDungeon(dungeon) {
return {
...dungeon,
completionCount: 0,
leaderboard: [],
leaderboards: {
part_1: [],
part_2: [],
part_3: [],
full_run: [],
},
}
}
export function normalizeCatalogProfile(profile) {
return {
...profile,
character: {
...profile.character,
id: 1,
name: 'Mira',
level: 1,
experience: 0,
talentPoints: 1,
currentLevelExperience: 0,
nextLevelExperience: 100,
},
abilitySlots: profile.abilitySlots,
allocatedTalentPoints: 0,
inventory: [],
completedDungeonParts: 0,
completedRaidPhases: 0,
gearStats: {
averageItemLevel: 0,
healingPower: 0,
maxResourceBonus: 0,
},
setBonuses: profile.setBonuses.map((bonus) => ({
...bonus,
equippedPieces: 0,
active: false,
})),
craftingRecipes: profile.craftingRecipes.map(normalizeRecipe),
dungeons: profile.dungeons.map(normalizeDungeon),
}
}
export function catalogHash(profile) {
return createHash('sha256')
.update(JSON.stringify(profile))
.digest('hex')
}
export function catalogPayload(profile) {
const normalized = normalizeCatalogProfile(profile)
return {
version: 1,
hash: catalogHash(normalized),
profile: normalized,
}
}
+182 -28
View File
@@ -9,6 +9,7 @@ import {
import { isIP } from 'node:net' import { isIP } from 'node:net'
import { extname, resolve, sep } from 'node:path' import { extname, resolve, sep } from 'node:path'
import { DatabaseSync } from 'node:sqlite' import { DatabaseSync } from 'node:sqlite'
import { catalogPayload } from './catalog.mjs'
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url)) const databasePath = fileURLToPath(new URL('../data/game.db', 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))
@@ -22,6 +23,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()
@@ -232,6 +234,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)) {
@@ -570,6 +591,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
@@ -728,6 +750,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,
@@ -770,6 +797,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()
@@ -814,6 +850,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)
@@ -822,6 +859,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,
@@ -844,11 +883,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,
), ),
@@ -1693,16 +1734,9 @@ function craftItem(database, characterId, recipeId) {
WHERE crafting_recipes.id = ? WHERE crafting_recipes.id = ?
`).get(recipeId) `).get(recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.') if (!recipe) throw new Error('That crafting recipe does not exist.')
const lowerTierRecipe = database.prepare(` if (!directCraftItemLevels.has(recipe.itemLevel)) {
SELECT crafting_recipes.id throw new Error('Upgrade the previous item tier instead.')
FROM crafting_recipes }
JOIN items ON items.id = crafting_recipes.item_id
WHERE crafting_recipes.source_encounter_id = ?
AND items.slot = ?
AND items.item_level < ?
LIMIT 1
`).get(recipe.sourceEncounterId, recipe.slot, recipe.itemLevel)
if (lowerTierRecipe) throw new Error('Upgrade the previous item tier instead.')
const components = database.prepare(` const components = database.prepare(`
SELECT SELECT
@@ -1716,6 +1750,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)
@@ -1769,24 +1806,39 @@ function upgradeItem(database, characterId, itemId) {
if (item.slot === componentSlot) throw new Error('Components cannot be upgraded.') if (item.slot === componentSlot) throw new Error('Components cannot be upgraded.')
const currentRecipe = database.prepare(` const currentRecipe = database.prepare(`
SELECT source_encounter_id AS sourceEncounterId SELECT id
FROM crafting_recipes FROM crafting_recipes
WHERE item_id = ? WHERE item_id = ?
`).get(itemId) `).get(itemId)
if (!currentRecipe) throw new Error('No upgrade is available for this item.') if (!currentRecipe) throw new Error('No upgrade is available for this item.')
const targetRecipe = database.prepare(` 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 SELECT
crafting_recipes.id, crafting_recipes.id,
crafting_recipes.item_id AS itemId crafting_recipes.item_id AS itemId
FROM crafting_recipes FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id JOIN items ON items.id = crafting_recipes.item_id
WHERE crafting_recipes.source_encounter_id = ? JOIN next_tier ON next_tier.item_level = items.item_level
AND items.slot = ? WHERE items.slot = ?
AND items.item_level > ? ORDER BY crafting_recipes.id
ORDER BY items.item_level
LIMIT 1 LIMIT 1
`).get(currentRecipe.sourceEncounterId, item.slot, item.itemLevel) `).get(item.slot, item.itemLevel, item.slot)
if (!targetRecipe) throw new Error('No upgrade is available for this item.') if (!targetRecipe) throw new Error('No upgrade is available for this item.')
const components = database.prepare(` const components = database.prepare(`
@@ -1800,6 +1852,10 @@ function upgradeItem(database, characterId, itemId) {
AND character_inventory.character_id = ? AND character_inventory.character_id = ?
WHERE crafting_recipe_components.recipe_id = ? WHERE crafting_recipe_components.recipe_id = ?
`).all(characterId, targetRecipe.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) const missing = components.find((component) => component.owned < component.quantity)
if (missing) { if (missing) {
const componentItem = itemById(database, missing.itemId) const componentItem = itemById(database, missing.itemId)
@@ -1849,9 +1905,20 @@ function upgradeItem(database, characterId, itemId) {
return getProfile(database, characterId) 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)
@@ -1863,7 +1930,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)
@@ -1871,6 +1939,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.')
} }
@@ -1949,11 +2071,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')
@@ -2024,12 +2148,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(`
@@ -2127,17 +2260,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, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 } bonusItem = { ...bonusItem, quantity: rewardQuantity, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + rewardQuantity }
} }
} }
@@ -2234,6 +2368,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
@@ -2248,7 +2388,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
@@ -2256,9 +2397,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
@@ -2548,6 +2697,11 @@ export async function handleApiRequest(request, response, next) {
return return
} }
if (request.url === '/api/catalog' && request.method === 'GET') {
sendJson(response, 200, catalogPayload(getProfile(database, 1)))
return
}
const session = requireSession(database, request) const session = requireSession(database, request)
if (request.url === '/api/profile/sync-save' && request.method === 'GET') { if (request.url === '/api/profile/sync-save' && request.method === 'GET') {
+2860 -10
View File
File diff suppressed because it is too large Load Diff
+194 -123
View File
@@ -55,6 +55,7 @@ const MENU_ITEMS: Array<{
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty' const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
const SHOW_LEADERBOARDS = false const SHOW_LEADERBOARDS = false
const ACTIVITY_PAGE_SIZE = 4
function activityInitials(name: string) { function activityInitials(name: string) {
return name return name
@@ -81,13 +82,14 @@ function App() {
return Number.isFinite(saved) && saved > 0 ? saved : 1 return Number.isFinite(saved) && saved > 0 ? saved : 1
}) })
const [selectedDungeonId, setSelectedDungeonId] = useState(1) const [selectedDungeonId, setSelectedDungeonId] = useState(1)
const [selectedRaidId, setSelectedRaidId] = useState(2) const [selectedRaidId, setSelectedRaidId] = useState(20)
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon') const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve') const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter') const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability') const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon') const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
const [selectedPart, setSelectedPart] = useState(1) const [selectedMarathonMode, setSelectedMarathonMode] = useState(false)
const [activityPage, setActivityPage] = useState(0)
const [combatContentId, setCombatContentId] = useState(1) const [combatContentId, setCombatContentId] = useState(1)
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1') const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
const [showLoot, setShowLoot] = useState(false) const [showLoot, setShowLoot] = useState(false)
@@ -230,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')
}} }}
@@ -283,6 +286,19 @@ function App() {
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId) const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
?? raidOptions[0] ?? raidOptions[0]
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 const tierOptions = activityOptions
.flatMap((option) => option.difficulties) .flatMap((option) => option.difficulties)
.filter((difficulty, index, all) => ( .filter((difficulty, index, all) => (
@@ -299,26 +315,24 @@ function App() {
?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel) ?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel)
?? tierOptions[0] ?? tierOptions[0]
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0 const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
const tierActivityOptions = activityOptions.filter((option) => const activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE))
option.difficulties.some((difficulty) => difficulty.droppedItemLevel === selectedTierItemLevel), 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 selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
const activity = tierActivityOptions.find((candidate) => candidate.id === selectedActivityId) const activity = activityOptions.find((candidate) => candidate.id === selectedActivityId)
?? tierActivityOptions[0] ?? activityOptions[0]
?? (screen === 'raids' && raid ? raid : dungeon) ?? (screen === 'raids' && raid ? raid : dungeon)
const selectedDifficulty = activity.difficulties.find( const selectedDifficulty = activity.difficulties.find(
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel, (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 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]
@@ -328,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"
@@ -425,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>
@@ -469,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>
</> </>
@@ -586,66 +606,60 @@ function App() {
)} )}
{(screen === 'dungeons' || screen === 'raids') && ( {(screen === 'dungeons' || screen === 'raids') && (
<section className="content-screen"> <section className="content-screen dungeon-run-screen">
<ScreenHeading <div className="dungeon-run-board">
eyebrow="Adventure" <div className="dungeon-run-main">
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'} <article className="run-summary-card dungeon-focus-card">
onBack={() => setScreen('menu')} <div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
/> {activityInitials(activity.name)}
<section className="run-setup-panel"> </div>
<div className="run-summary-copy">
<p className="eyebrow">Selected Run</p>
<div className="run-title-row">
<h2>{activity.name}</h2>
<button className="back-button inline-back-button" onClick={() => setScreen('menu')} type="button">Back</button>
</div>
<p>{activity.description}</p>
<div className="tag-row">
<span>Level {activity.recommendedLevel}</span>
<span>{activity.partySize} Players</span>
<span>{selectedDifficulty.name}</span>
<span>iLvl {selectedDifficulty.droppedItemLevel}</span>
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
</div>
</div>
</article>
<section className="run-setup-panel dungeon-choice-panel">
<div className="run-setup-heading"> <div className="run-setup-heading">
<div> <div>
<p className="eyebrow">Step 1</p> <p className="eyebrow">Pick Run</p>
<h2>Item Level</h2> <h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
</div> </div>
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by character level.</small> {activityPageCount > 1 ? (
</div> <div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
<div className="tier-grid">
{tierOptions.map((difficulty) => {
const locked = profile.character.level < difficulty.unlockLevel
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
return (
<button <button
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`} disabled={currentActivityPage === 0}
disabled={locked} onClick={() => setActivityPage((page) => Math.max(0, page - 1))}
key={difficulty.id}
onClick={() => {
const nextActivity = activity.difficulties.some(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
? activity
: activityOptions.find((option) =>
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
)
if (nextActivity) {
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
else setSelectedDungeonId(nextActivity.id)
const nextDifficulty = nextActivity.difficulties.find(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
}
}}
type="button" type="button"
> >
<strong>iLvl {difficulty.droppedItemLevel}</strong> Prev
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span> </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> </button>
)
})}
</div>
</section>
<section className="run-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Step 2</p>
<h2>{screen === 'raids' ? 'Pick Raid' : 'Pick Dungeon'}</h2>
</div> </div>
) : (
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small> <small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
)}
</div> </div>
<div className="activity-card-grid"> <div className="activity-card-grid dungeon-choice-grid">
{tierActivityOptions.map((candidate) => { {pagedActivityOptions.map((candidate) => {
const difficulty = candidate.difficulties.find( const difficulty = candidate.difficulties.find(
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel, (option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
) ?? candidate.difficulties[0] ) ?? candidate.difficulties[0]
@@ -676,42 +690,96 @@ function App() {
})} })}
</div> </div>
</section> </section>
</div>
<article className="run-summary-card"> <aside className="dungeon-setup-rail">
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}> <section className="run-setup-panel tier-setup-panel">
{activityInitials(activity.name)} <div className="run-setup-heading">
<div>
<p className="eyebrow">Item Level</p>
<h2>Tier</h2>
</div> </div>
<div className="run-summary-copy"> <small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
<p className="eyebrow">Step 3</p>
<h2>{activity.name}</h2>
<p>{activity.description}</p>
<div className="tag-row">
<span>Level {activity.recommendedLevel}</span>
<span>{activity.partySize} Players</span>
<span>{selectedDifficulty.name}</span>
<span>iLvl {selectedDifficulty.droppedItemLevel}</span>
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
</div> </div>
<div className="tier-grid">
{tierOptions.map((difficulty) => {
const locked = profile.character.level < difficulty.unlockLevel
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
return (
<button
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
disabled={locked}
key={difficulty.id}
onClick={() => {
setActivityPage(0)
const nextActivity = activity.difficulties.some(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
? activity
: activityOptions.find((option) =>
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
)
if (nextActivity) {
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
else setSelectedDungeonId(nextActivity.id)
const nextDifficulty = nextActivity.difficulties.find(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
}
}}
type="button"
>
<strong>iLvl {difficulty.droppedItemLevel}</strong>
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
</button>
)
})}
</div>
</section>
<section className="run-setup-panel part-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Start</p>
<h2>Run</h2>
</div>
<small>
{difficultyLocked
? `Unlocks at level ${selectedDifficulty.unlockLevel}`
: 'Marathon keeps health and mana between boss kills.'}
</small>
</div> </div>
<div className="part-picker"> <div className="part-picker">
{parts.map((p) => (
<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) 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-summary ${difficultyLocked ? 'locked' : ''}`}> <div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
<div> <div>
@@ -722,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>
@@ -820,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}
@@ -874,6 +943,8 @@ function App() {
)} )}
</div> </div>
)} )}
</aside>
</div>
</section> </section>
)} )}
File diff suppressed because it is too large Load Diff
+430 -96
View File
@@ -10,6 +10,10 @@ import {
import { import {
INITIAL_PARTY, INITIAL_PARTY,
RAID_PARTY, RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
groupHealTargets,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry, type CombatLogEntry,
type PartyMember, type PartyMember,
type Spell, type Spell,
@@ -35,12 +39,11 @@ import {
} from '../dualScreen' } from '../dualScreen'
const TICK_MS = 700 const TICK_MS = 700
const TARGET_RENDER_THROTTLE_MS = 180
type RoguelikeMode = 'dungeon' | 'raid' type RoguelikeMode = 'dungeon' | 'raid'
type RoguelikeUpgradeTiming = 'boss' | 'encounter' type RoguelikeUpgradeTiming = 'boss' | 'encounter'
type RoguelikeAbilityLabelMode = 'ability' | 'slot' type RoguelikeAbilityLabelMode = 'ability' | 'slot'
type SlotKey = '1' | '2' | '3' | '4' | '5' type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
type RoguelikeMechanic = type RoguelikeMechanic =
| 'party-pulse' | 'party-pulse'
| 'searing-mark' | 'searing-mark'
@@ -102,12 +105,53 @@ function effectiveMaxHealth(member: PartyMember) {
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1))) return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
} }
function healAmount(member: PartyMember, amount: number) { function healAmount(member: PartyMember, amount: number, multiplier = 1) {
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1)) return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier)
} }
function healMember(member: PartyMember, amount: number) { function healMember(member: PartyMember, amount: number, multiplier = 1) {
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member)) return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member))
}
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 }]
: []
}
function effectId(prefix: string) {
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`
}
function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
const nextEffect = {
id: effectId(spell.id),
spellId: spell.id,
label: spell.name,
ticks,
power: Math.max(1, Math.round(spell.power / 2)),
}
const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id)
return [...currentEffects, nextEffect]
}
function addBounceHeal(member: PartyMember, spell: Spell) {
return [
...(member.bounceHeals ?? []),
{
id: effectId(spell.id),
label: spell.name,
charges: 4,
power: spell.power,
},
]
}
function tickHotEffects(effects: PartyMember['hotEffects']) {
return (effects ?? [])
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
.filter((effect) => effect.ticks > 0)
} }
function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) { function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
@@ -124,7 +168,7 @@ function buildRoguelikeUpgrades(
spells: Spell[], spells: Spell[],
labelMode: RoguelikeAbilityLabelMode, labelMode: RoguelikeAbilityLabelMode,
): RoguelikeUpgrade[] { ): RoguelikeUpgrade[] {
const slotUpgrades = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => { const slotUpgrades = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
const label = slotLabel(slot, spells, labelMode) const label = slotLabel(slot, spells, labelMode)
return [ return [
{ {
@@ -192,9 +236,14 @@ function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastR
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell { function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
const kinds: Record<string, Spell['kind']> = { const kinds: Record<string, Spell['kind']> = {
direct_heal: 'direct', direct_heal: 'direct',
direct_hot: 'direct',
heal_over_time: 'hot', heal_over_time: 'hot',
party_heal: 'group', party_heal: 'group',
party_hot: 'group',
party_absorb: 'group',
absorb: 'shield', absorb: 'shield',
damage_reduction: 'damage_reduction',
bounce_heal: 'bounce_heal',
cleanse: 'cleanse', cleanse: 'cleanse',
} }
return { return {
@@ -207,6 +256,7 @@ function toCombatSpell(ability: Ability, key: string, healingPower: number): Spe
power: ability.power + healingPower, power: ability.power + healingPower,
glyph: ability.glyph, glyph: ability.glyph,
kind: kinds[ability.spellType] ?? 'direct', kind: kinds[ability.spellType] ?? 'direct',
effectType: ability.spellType,
} }
} }
@@ -290,6 +340,8 @@ function makeRoguelikeSegment(
export function CombatScreen({ export function CombatScreen({
difficulty, difficulty,
dungeon, dungeon,
hardMode = false,
marathonMode = false,
profile, profile,
startPart = 1, startPart = 1,
roguelikeMode, roguelikeMode,
@@ -301,6 +353,8 @@ export function CombatScreen({
}: { }: {
difficulty: Difficulty difficulty: Difficulty
dungeon: Dungeon dungeon: Dungeon
hardMode?: boolean
marathonMode?: boolean
profile: CharacterProfile profile: CharacterProfile
startPart?: number startPart?: number
roguelikeMode?: RoguelikeMode roguelikeMode?: RoguelikeMode
@@ -348,23 +402,25 @@ export function CombatScreen({
})), })),
[dungeon.partySize, profile.character.name], [dungeon.partySize, profile.character.name],
) )
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part' const sectionName = isRoguelike ? 'Stage' : 'Run'
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon' const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
const initialEncounterIndex = (startPart - 1) * 3 const initialEncounterIndex = (startPart - 1) * 3
const enemyCount = hardMode ? 2 : 1
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({ const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
party: partyTemplate, party: partyTemplate,
resource: maxResource, resource: maxResource,
enemyHealth: encounters[initialEncounterIndex].maxHealth, enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount,
cooldowns: {}, cooldowns: {},
elapsedTicks: 0, elapsedTicks: 0,
castsTowardFree: 0, castsTowardFree: 0,
freeCastReady: false, freeCastReady: false,
}), [encounters, initialEncounterIndex, maxResource, partyTemplate]) }), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState) const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
const [selectedId, setSelectedId] = useState(partyTemplate[0].id) const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex) const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing') const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'>('playing')
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false)
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0) const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
const [log, setLog] = useState<CombatLogEntry[]>([ const [log, setLog] = useState<CombatLogEntry[]>([
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' }, { id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
@@ -376,37 +432,58 @@ export function CombatScreen({
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([]) const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([]) const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([]) const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
const [marathonBossesDefeated, setMarathonBossesDefeated] = useState(0)
const rewardClaimedRef = useRef(false) const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false) const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<number>()) const rolledEncounterIdsRef = useRef(new Set<string>())
const runTokenRef = useRef(crypto.randomUUID()) const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0) const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0) const runStartedAtRef = useRef(0)
const partStartTimesRef = useRef<Record<number, number>>({}) const partStartTimesRef = useRef<Record<number, number>>({})
const nextLogId = useRef(2) const nextLogId = useRef(2)
const nextFloatingTextId = useRef(1) const nextFloatingTextId = useRef(1)
const marathonBossesDefeatedRef = useRef(0)
const combatRef = useRef(initialCombatState) const combatRef = useRef(initialCombatState)
const selectedIdRef = useRef(partyTemplate[0].id) const selectedIdRef = useRef(partyTemplate[0].id)
const selectedRenderTimeoutRef = useRef<number | null>(null) const runCombatTickRef = useRef<() => void>(() => {})
const lastSelectedRenderAtRef = useRef(0) const combatClockActiveRef = useRef(false)
const lastCombatTickAtRef = useRef(performance.now())
const statusRef = useRef(status)
const pausedRef = useRef(paused)
const speedMultiplierRef = useRef<1 | 2>(speedMultiplier)
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
const encounter = encounters[encounterIndex] const encounter = encounters[encounterIndex]
const encounterMaxHealth = encounter.maxHealth * enemyCount
const currentPart = getCurrentPart(encounterIndex) const currentPart = getCurrentPart(encounterIndex)
const completedSections = dungeon.contentType === 'raid'
? profile.completedRaidPhases
: profile.completedDungeonParts
const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1
const firstEncounterIndex = (startPart - 1) * 3 const firstEncounterIndex = (startPart - 1) * 3
const expectedLootRolls = encounters const expectedLootRolls = encounters
.slice(firstEncounterIndex, encounterIndex + 1) .slice(firstEncounterIndex, encounterIndex + 1)
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id)) .filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
.length .length * enemyCount
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2 const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1 const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
const playerHealer = party.find((member) => member.id === 'mira') const playerHealer = party.find((member) => member.id === 'mira')
const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0) const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0)
const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter' const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter'
const activeSetEffects = useMemo( const activeEffects = useMemo(
() => isRoguelike () => {
? new Set<string>() const effects = new Set<string>(
: new Set(profile.setBonuses.filter((bonus) => bonus.active).map((bonus) => bonus.effectType)), gameClass.talents
[isRoguelike, profile.setBonuses], .filter((talent) => talent.rank > 0)
.map((talent) => talent.effectType),
)
if (!isRoguelike) {
profile.setBonuses
.filter((bonus) => bonus.active)
.forEach((bonus) => effects.add(bonus.effectType))
}
return effects
},
[gameClass.talents, isRoguelike, profile.setBonuses],
) )
const { const {
bindings, bindings,
@@ -418,6 +495,10 @@ export function CombatScreen({
enabled: dualScreenEnabled, enabled: dualScreenEnabled,
} = useDualScreen() } = useDualScreen()
statusRef.current = status
pausedRef.current = paused
speedMultiplierRef.current = speedMultiplier
useEffect(() => { useEffect(() => {
const now = Date.now() const now = Date.now()
runStartedAtRef.current = now runStartedAtRef.current = now
@@ -438,32 +519,27 @@ export function CombatScreen({
? nextState(combatRef.current) ? nextState(combatRef.current)
: nextState : nextState
combatRef.current = next combatRef.current = next
setSelectedId(selectedIdRef.current)
setCombatState(next) setCombatState(next)
}, []) }, [])
const syncSelectedTargetDom = useCallback((id: string) => {
document.querySelectorAll<HTMLButtonElement>('[data-party-member-id]').forEach((button) => {
const selected = button.dataset.partyMemberId === id
button.classList.toggle('selected', selected)
button.setAttribute('aria-pressed', String(selected))
})
}, [])
const setSelectedTargetId = useCallback((id: string) => { const setSelectedTargetId = useCallback((id: string) => {
if (selectedIdRef.current === id) return if (selectedIdRef.current === id) return
selectedIdRef.current = id selectedIdRef.current = id
const now = performance.now() syncSelectedTargetDom(id)
const elapsed = now - lastSelectedRenderAtRef.current }, [syncSelectedTargetDom])
if (elapsed >= TARGET_RENDER_THROTTLE_MS) {
lastSelectedRenderAtRef.current = now
setSelectedId(id)
return
}
if (selectedRenderTimeoutRef.current !== null) return
selectedRenderTimeoutRef.current = window.setTimeout(() => {
selectedRenderTimeoutRef.current = null
lastSelectedRenderAtRef.current = performance.now()
setSelectedId(selectedIdRef.current)
}, TARGET_RENDER_THROTTLE_MS - elapsed)
}, [])
useEffect(() => () => { useEffect(() => {
if (selectedRenderTimeoutRef.current !== null) { syncSelectedTargetDom(selectedIdRef.current)
window.clearTimeout(selectedRenderTimeoutRef.current) }, [combatState, syncSelectedTargetDom])
}
}, [])
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => { const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
const entry = { id: nextLogId.current++, text, tone } const entry = { id: nextLogId.current++, text, tone }
@@ -480,10 +556,12 @@ export function CombatScreen({
}, []) }, [])
const requestLootRoll = useCallback( const requestLootRoll = useCallback(
(encounterId: number) => { (encounterId: number, rollIndex = 0) => {
if (rolledEncounterIdsRef.current.has(encounterId)) return const rollKey = `${encounterId}:${rollIndex}:${marathonBossesDefeatedRef.current}`
rolledEncounterIdsRef.current.add(encounterId) if (rolledEncounterIdsRef.current.has(rollKey)) return
rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current) rolledEncounterIdsRef.current.add(rollKey)
const runToken = `${runTokenRef.current}-${marathonBossesDefeatedRef.current}-${rollIndex}`
rollEncounterLoot(encounterId, difficulty.id, runToken)
.then((result) => { .then((result) => {
setLootRolls((current) => [...current, result]) setLootRolls((current) => [...current, result])
const awarded = result.items const awarded = result.items
@@ -515,7 +593,7 @@ export function CombatScreen({
setCombat({ setCombat({
party: freshParty, party: freshParty,
resource: maxResource, resource: maxResource,
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth, enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount,
cooldowns: {}, cooldowns: {},
elapsedTicks: 0, elapsedTicks: 0,
castsTowardFree: 0, castsTowardFree: 0,
@@ -535,15 +613,17 @@ export function CombatScreen({
setFloatingTexts([]) setFloatingTexts([])
setRoguelikeUpgrades([]) setRoguelikeUpgrades([])
setUpgradeChoices([]) setUpgradeChoices([])
setMarathonBossesDefeated(0)
rewardClaimedRef.current = false rewardClaimedRef.current = false
profileRefreshedRef.current = false profileRefreshedRef.current = false
rolledEncounterIdsRef.current = new Set() rolledEncounterIdsRef.current = new Set()
runTokenRef.current = crypto.randomUUID() runTokenRef.current = crypto.randomUUID()
marathonBossesDefeatedRef.current = 0
resourceSpentRef.current = 0 resourceSpentRef.current = 0
runStartedAtRef.current = Date.now() runStartedAtRef.current = Date.now()
partStartTimesRef.current = { [startPart]: runStartedAtRef.current } partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }]) setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters]) }, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
const castSpell = useCallback( const castSpell = useCallback(
(spell: Spell) => { (spell: Spell) => {
@@ -558,23 +638,36 @@ export function CombatScreen({
const extraTarget = (blockedIds: string[]) => current.party const extraTarget = (blockedIds: string[]) => current.party
.filter((member) => member.health > 0 && !blockedIds.includes(member.id)) .filter((member) => member.health > 0 && !blockedIds.includes(member.id))
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0] .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
const effectSpell = (name: string) => {
const ability = gameClass.spells.find((candidate) => candidate.name === name)
return ability ? toCombatSpell(ability, `effect-${ability.id}`, healingPower) : null
}
const renewEffect = effectSpell('Renew')
const shieldEffect = effectSpell('Sun Ward')
const healingMultiplier = (member: PartyMember) =>
activeEffects.has('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
const directTargets = new Set([targetId]) const directTargets = new Set([targetId])
const hotTargets = new Set<string>() const hotTargets = new Set<string>()
const shieldTargets = new Set<string>() const shieldTargets = new Set<string>()
if (spell.kind === 'hot') hotTargets.add(targetId) const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
const groupTargets = new Set(
spell.kind === 'group'
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
: [],
)
if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId)
if (spell.kind === 'shield') shieldTargets.add(targetId) if (spell.kind === 'shield') shieldTargets.add(targetId)
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) { if (spell.name === 'Mend' && activeEffects.has('mend_extra_target')) {
const extra = extraTarget([targetId]) const extra = extraTarget([targetId])
if (extra) directTargets.add(extra.id) if (extra) directTargets.add(extra.id)
} }
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) { if (spell.name === 'Renew' && activeEffects.has('renew_extra_target')) {
const extra = extraTarget([targetId]) const extra = extraTarget([targetId])
if (extra) hotTargets.add(extra.id) if (extra) hotTargets.add(extra.id)
} }
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) { if (spell.name === 'Mend' && activeEffects.has('mend_applies_renew')) {
hotTargets.add(targetId) hotTargets.add(targetId)
} }
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
for (let index = 0; index < extraTargets; index += 1) { for (let index = 0; index < extraTargets; index += 1) {
if (spell.kind === 'group') break if (spell.kind === 'group') break
if (spell.kind === 'hot') { if (spell.kind === 'hot') {
@@ -594,20 +687,61 @@ export function CombatScreen({
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') {
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost'))) if (!groupTargets.has(member.id)) return member
const nextHealth = healMember(member, power) if (spell.effectType === 'party_absorb') {
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
return { ...member, health: nextHealth }
}
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
if (spell.kind === 'shield') {
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost'))) const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
return { ...member, shield: Math.max(member.shield, power) } return { ...member, shield: Math.max(member.shield, power) }
} }
if (spell.effectType === 'party_hot') {
return {
...member,
hotTicks: 0,
hotEffects: addHotEffect(member, spell),
}
}
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
const nextHealth = healMember(member, power, healingMultiplier(member))
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
const nextShield = spell.name === 'Radiance' && activeEffects.has('radiance_applies_shield')
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3))
: member.shield
return {
...member,
health: nextHealth,
shield: nextShield,
hotTicks: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') ? 0 : member.hotTicks,
hotEffects: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') && renewEffect
? addHotEffect(member, renewEffect, 3)
: member.hotEffects,
}
}
if (
!directTargets.has(member.id)
&& !hotTargets.has(member.id)
&& !shieldTargets.has(member.id)
&& !(member.id === targetId && (spell.kind === 'damage_reduction' || spell.kind === 'bounce_heal'))
) return member
if (spell.kind === 'shield') {
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
return {
...member,
hotTicks: activeEffects.has('shield_applies_renew') && renewEffect ? 0 : member.hotTicks,
hotEffects: activeEffects.has('shield_applies_renew') && renewEffect
? addHotEffect(member, renewEffect)
: member.hotEffects,
shield: Math.max(member.shield, power),
}
}
if (spell.kind === 'damage_reduction') {
return { ...member, damageReductionTicks: 12 }
}
if (spell.kind === 'bounce_heal') {
return { ...member, bounceHeals: addBounceHeal(member, spell) }
}
if (spell.kind === 'cleanse') { if (spell.kind === 'cleanse') {
return { return {
...member, ...member,
health: healMember(member, spell.power), health: healMember(member, spell.power, healingMultiplier(member)),
debuff: undefined, debuff: undefined,
debuffTicks: undefined, debuffTicks: undefined,
poisonStacks: undefined, poisonStacks: undefined,
@@ -616,13 +750,23 @@ export function CombatScreen({
} }
} }
const nextHealth = directTargets.has(member.id) const nextHealth = directTargets.has(member.id)
? healMember(member, spell.power) ? healMember(member, spell.power, healingMultiplier(member))
: member.health : member.health
if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health) if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health)
const nextShield = spell.name === 'Mend' && directTargets.has(member.id) && activeEffects.has('mend_applies_shield')
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5))
: member.shield
const appliedHotSpell = spell.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect
? renewEffect
: spell
return { return {
...member, ...member,
health: nextHealth, health: nextHealth,
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, shield: nextShield,
hotTicks: 0,
hotEffects: hotTargets.has(member.id)
? addHotEffect(member, appliedHotSpell)
: member.hotEffects,
} }
}) })
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free') const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
@@ -640,20 +784,26 @@ export function CombatScreen({
&& !current.freeCastReady && !current.freeCastReady
&& current.castsTowardFree + 1 >= 5 && current.castsTowardFree + 1 >= 5
resourceSpentRef.current += effectiveCost resourceSpentRef.current += effectiveCost
const nextCooldowns = {
...current.cooldowns,
}
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
const radiance = spells.find((candidate) => candidate.name === 'Radiance')
if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2)
}
nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades)
setCombat({ setCombat({
...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, roguelikeUpgrades),
},
castsTowardFree: nextCastsTowardFree, castsTowardFree: nextCastsTowardFree,
freeCastReady: gainedFreeCast || nextFreeCastReady, freeCastReady: gainedFreeCast || nextFreeCastReady,
}) })
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal') addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
}, },
[activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, status], [activeEffects, addFloatingHeal, addLog, gameClass.spells, healingPower, roguelikeUpgrades, setCombat, spells, status],
) )
const finishRun = useCallback( const finishRun = useCallback(
@@ -676,6 +826,7 @@ export function CombatScreen({
completedPart, completedPart,
runStartPart, runStartPart,
[partDuration(1), partDuration(2), partDuration(3)], [partDuration(1), partDuration(2), partDuration(3)],
hardMode,
) )
.then((result) => { .then((result) => {
setReward(result) setReward(result)
@@ -688,7 +839,7 @@ export function CombatScreen({
) )
}) })
}, },
[difficulty.id, dungeon.id, onProfileUpdated], [difficulty.id, dungeon.id, hardMode, onProfileUpdated],
) )
const finishRoguelikeRun = useCallback( const finishRoguelikeRun = useCallback(
@@ -786,6 +937,9 @@ export function CombatScreen({
poisonStacks: undefined, poisonStacks: undefined,
maxHealthPenaltyTicks: undefined, maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined, healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
})) }))
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
const nextSegment = clearedBoss const nextSegment = clearedBoss
@@ -804,7 +958,7 @@ export function CombatScreen({
setCombat({ setCombat({
...current, ...current,
party: recoveredParty, party: recoveredParty,
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth * enemyCount,
elapsedTicks: 0, elapsedTicks: 0,
cooldowns: {}, cooldowns: {},
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource), resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
@@ -812,9 +966,13 @@ export function CombatScreen({
setUpgradeChoices([]) setUpgradeChoices([])
setStatus('playing') setStatus('playing')
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system') addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat]) }, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
useGameAction((action, device) => { useGameAction((action, device) => {
if (action === 'toggleSpeed') {
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
return
}
if (action === 'pause' || (action === 'back' && device === 'pc')) { if (action === 'pause' || (action === 'back' && device === 'pc')) {
if (status === 'playing') setPaused((value) => !value) if (status === 'playing') setPaused((value) => !value)
return return
@@ -854,9 +1012,7 @@ export function CombatScreen({
if (spell) castSpell(spell) if (spell) castSpell(spell)
}) })
useEffect(() => { const runCombatTick = useCallback(() => {
if (status !== 'playing' || paused) return
const timer = window.setInterval(() => {
const current = combatRef.current const current = combatRef.current
const nextElapsedTicks = current.elapsedTicks + 1 const nextElapsedTicks = current.elapsedTicks + 1
const nextCooldowns = Object.fromEntries( const nextCooldowns = Object.fromEntries(
@@ -904,19 +1060,53 @@ export function CombatScreen({
} }
const healerBeforeDamage = current.party.find((member) => member.id === 'mira') const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
const nextParty = current.party.map((member) => { const tankPressure = tankPressureTargets(current.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const pendingJumpHeals: Array<{
targetId: string
heal: NonNullable<PartyMember['bounceHeals']>[number]
}> = []
const damagedParty = current.party.map((member) => {
if (member.health <= 0) return member if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounter.damage : 0 let damage = member.id === primaryTarget.id ? encounter.damage : 0
if (member.role === 'Tank') damage += encounter.tankDamage if (tankPressureIds.has(member.id)) {
if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier) damage += Math.round(encounter.tankDamage * tankPressure.multiplier)
}
if (tankBuster && tankPressureIds.has(member.id)) {
damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier)
}
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier) if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier) if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
? Math.max(1, (member.poisonStacks ?? 0) + 1) ? Math.max(1, (member.poisonStacks ?? 0) + 1)
: member.poisonStacks ?? 0 : member.poisonStacks ?? 0
if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier) if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
damage *= enemyCount
if ((member.damageReductionTicks ?? 0) > 0) {
damage = Math.round(damage * 0.5)
}
if (member.shield > 0 && activeEffects.has('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) : 0 const hotEffects = memberHotEffects(member)
const healingMultiplier = member.shield > 0 && activeEffects.has('shielded_healing_bonus') ? 1.2 : 1
let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0)
let nextBounceHeals = [...(member.bounceHeals ?? [])]
if (damage > 0 && nextBounceHeals.length > 0) {
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
healing += healAmount(member, effect.power, healingMultiplier)
const nextCharges = effect.charges - 1
if (nextCharges <= 0) return []
const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id)
const jumpTarget = jumpTargets[Math.floor(Math.random() * jumpTargets.length)] ?? member
pendingJumpHeals.push({
targetId: jumpTarget.id,
heal: { ...effect, charges: nextCharges },
})
return []
})
}
if (healing > 0) addFloatingHeal(member.id, healing) if (healing > 0) addFloatingHeal(member.id, healing)
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
? 15 ? 15
@@ -930,9 +1120,12 @@ export function CombatScreen({
: Math.max(0, (member.debuffTicks ?? 0) - 1) : Math.max(0, (member.debuffTicks ?? 0) - 1)
return { return {
...member, ...member,
health: clamp(member.health - damage + absorbed + healing, 0, nextEffectiveMaxHealth), health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth),
shield: Math.max(0, member.shield - damage), shield: Math.max(0, member.shield - damage),
hotTicks: Math.max(0, member.hotTicks - 1), hotTicks: 0,
hotEffects: tickHotEffects(hotEffects),
bounceHeals: nextBounceHeals,
damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1),
debuff: nextDebuffTicks > 0 debuff: nextDebuffTicks > 0
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff) ? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
: undefined, : undefined,
@@ -942,6 +1135,17 @@ export function CombatScreen({
healingReductionTicks: nextHealingReductionTicks, healingReductionTicks: nextHealingReductionTicks,
} }
}) })
const nextParty = damagedParty.map((member) => {
const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id)
if (jumped.length === 0) return member
return {
...member,
bounceHeals: [
...(member.bounceHeals ?? []),
...jumped.map((jump) => jump.heal),
],
}
})
const healerAfterDamage = nextParty.find((member) => member.id === 'mira') const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
if ( if (
@@ -968,7 +1172,7 @@ export function CombatScreen({
return return
} }
const nextEnemyHealth = current.enemyHealth - encounter.partyDamage const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage)
if (nextEnemyHealth > 0) { if (nextEnemyHealth > 0) {
setCombat({ setCombat({
...current, ...current,
@@ -982,7 +1186,9 @@ export function CombatScreen({
} }
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) { if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
requestLootRoll(encounter.id) for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
requestLootRoll(encounter.id, rollIndex)
}
} }
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) { if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
@@ -1001,6 +1207,9 @@ export function CombatScreen({
} }
if (isPartBoss && !isFinalBoss) { if (isPartBoss && !isFinalBoss) {
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
marathonBossesDefeatedRef.current = nextMarathonKills
setMarathonBossesDefeated(nextMarathonKills)
setCombat({ setCombat({
...current, ...current,
party: nextParty, party: nextParty,
@@ -1009,12 +1218,28 @@ export function CombatScreen({
elapsedTicks: nextElapsedTicks, elapsedTicks: nextElapsedTicks,
enemyHealth: 0, enemyHealth: 0,
}) })
setStatus('part-complete') setStatus(marathonMode && encounter.isBoss ? 'marathon-choice' : 'part-complete')
addLog(`${encounter.enemyName} is defeated.`, 'loot') addLog(`${encounter.enemyName} is defeated.`, 'loot')
return return
} }
if (encounterIndex === encounters.length - 1) { if (encounterIndex === encounters.length - 1) {
if (marathonMode && encounter.isBoss) {
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
marathonBossesDefeatedRef.current = nextMarathonKills
setMarathonBossesDefeated(nextMarathonKills)
setCombat({
...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: 0,
})
setStatus('marathon-choice')
addLog(`${encounter.enemyName} is defeated. Continue marathon or end the hunt.`, 'loot')
return
}
setCombat({ setCombat({
...current, ...current,
party: nextParty, party: nextParty,
@@ -1039,6 +1264,9 @@ export function CombatScreen({
poisonStacks: undefined, poisonStacks: undefined,
maxHealthPenaltyTicks: undefined, maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined, healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
})) }))
setEncounterIndex((value) => value + 1) setEncounterIndex((value) => value + 1)
setCombat({ setCombat({
@@ -1047,15 +1275,15 @@ export function CombatScreen({
resource: nextResource, resource: nextResource,
cooldowns: nextCooldowns, cooldowns: nextCooldowns,
elapsedTicks: 0, elapsedTicks: 0,
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth * enemyCount,
}) })
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system') addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
}, TICK_MS)
return () => window.clearInterval(timer)
}, [ }, [
activeEffects,
addLog, addLog,
addFloatingHeal, addFloatingHeal,
difficulty.damageMultiplier, difficulty.damageMultiplier,
enemyCount,
encounter, encounter,
encounterIndex, encounterIndex,
encounters, encounters,
@@ -1064,6 +1292,7 @@ export function CombatScreen({
isPartBoss, isPartBoss,
isFinalBoss, isFinalBoss,
isRoguelike, isRoguelike,
marathonMode,
upgradesEveryEncounter, upgradesEveryEncounter,
roguelikeUpgradeCatalog, roguelikeUpgradeCatalog,
roguelikeUpgrades, roguelikeUpgrades,
@@ -1073,11 +1302,44 @@ export function CombatScreen({
profile.character.name, profile.character.name,
setCombat, setCombat,
startPart, startPart,
status,
currentPart, currentPart,
paused,
]) ])
useEffect(() => {
runCombatTickRef.current = runCombatTick
}, [runCombatTick])
useEffect(() => {
if (status === 'playing' && !paused) {
if (!combatClockActiveRef.current) {
lastCombatTickAtRef.current = performance.now()
combatClockActiveRef.current = true
}
return
}
combatClockActiveRef.current = false
}, [paused, status])
useEffect(() => {
const timer = window.setInterval(() => {
if (
!combatClockActiveRef.current
|| statusRef.current !== 'playing'
|| pausedRef.current
) return
const now = performance.now()
const tickMs = TICK_MS / speedMultiplierRef.current
const dueTicks = Math.min(8, Math.floor((now - lastCombatTickAtRef.current) / tickMs))
if (dueTicks <= 0) return
lastCombatTickAtRef.current += dueTicks * tickMs
for (let index = 0; index < dueTicks; index += 1) {
if (statusRef.current !== 'playing' || pausedRef.current) return
runCombatTickRef.current()
}
}, 50)
return () => window.clearInterval(timer)
}, [])
useEffect(() => { useEffect(() => {
if ( if (
!reward !reward
@@ -1092,7 +1354,15 @@ export function CombatScreen({
}) })
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward]) }, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
const enemyPercent = (enemyHealth / encounter.maxHealth) * 100 const enemyPercent = (enemyHealth / encounterMaxHealth) * 100
const enemyHealthSegments = Array.from({ length: enemyCount }, (_, index) => {
const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth)
return {
index,
health: remaining,
percent: (remaining / encounter.maxHealth) * 100,
}
}).reverse()
const dualScreenState = useMemo<DualScreenCombatState>(() => ({ const dualScreenState = useMemo<DualScreenCombatState>(() => ({
difficultyName: difficulty.name, difficultyName: difficulty.name,
dungeonName: dungeon.name, dungeonName: dungeon.name,
@@ -1100,7 +1370,7 @@ export function CombatScreen({
encounterName: encounter.enemyName, encounterName: encounter.enemyName,
encounterDescription: encounter.description, encounterDescription: encounter.description,
encounterHealth: enemyHealth, encounterHealth: enemyHealth,
encounterMaxHealth: encounter.maxHealth, encounterMaxHealth,
encounterIsBoss: encounter.isBoss, encounterIsBoss: encounter.isBoss,
encounterIndex, encounterIndex,
encounterCount: encounters.length, encounterCount: encounters.length,
@@ -1130,6 +1400,7 @@ export function CombatScreen({
directPartyTargeting, directPartyTargeting,
paused, paused,
targetGroup, targetGroup,
speedMultiplier,
}), [ }), [
bindings, bindings,
controllerIconStyle, controllerIconStyle,
@@ -1142,7 +1413,8 @@ export function CombatScreen({
encounter.description, encounter.description,
encounter.enemyName, encounter.enemyName,
encounter.isBoss, encounter.isBoss,
encounter.maxHealth, encounterMaxHealth,
hardMode,
enemyHealth, enemyHealth,
encounterIndex, encounterIndex,
encounters.length, encounters.length,
@@ -1159,6 +1431,7 @@ export function CombatScreen({
spells, spells,
freeCastReady, freeCastReady,
roguelikeUpgrades, roguelikeUpgrades,
speedMultiplier,
status, status,
targetGroup, targetGroup,
]) ])
@@ -1195,9 +1468,20 @@ export function CombatScreen({
<div className="enemy-info"> <div className="enemy-info">
<div className="bar-label"> <div className="bar-label">
<strong>{encounter.enemyName}</strong> <strong>{encounter.enemyName}</strong>
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span> <span>{Math.ceil(enemyHealth)} / {encounterMaxHealth}</span>
</div> </div>
{hardMode ? (
<div className="hard-enemy-bars">
{enemyHealthSegments.map((segment) => (
<div className="bar enemy-health" key={segment.index}>
<span style={{ width: `${segment.percent}%` }} />
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
</div>
))}
</div>
) : (
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div> <div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
)}
<p>{encounter.description}</p> <p>{encounter.description}</p>
</div> </div>
</section> </section>
@@ -1211,6 +1495,7 @@ export function CombatScreen({
? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}` ? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}`
: `${profile.character.name} is defeated`} : `${profile.character.name} is defeated`}
</span> </span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div> <div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
</div> </div>
</div> </div>
@@ -1218,17 +1503,16 @@ export function CombatScreen({
{party.map((member) => ( {party.map((member) => (
<button <button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`} className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
data-party-member-id={member.id}
key={member.id} key={member.id}
onClick={() => setSelectedTargetId(member.id)} onClick={() => setSelectedTargetId(member.id)}
aria-pressed={selectedId === member.id} aria-pressed={selectedId === member.id}
type="button" type="button"
> >
{selectedId === member.id && (
<span className="target-marker" aria-hidden="true"> <span className="target-marker" aria-hidden="true">
<i /> <i />
Target Target
</span> </span>
)}
<div className="member-header"> <div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span> <span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong> <strong>{member.name}</strong>
@@ -1245,7 +1529,14 @@ export function CombatScreen({
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)} .map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div> </div>
<div className="member-effects"> <div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>} {memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
))}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
{(member.bounceHeals ?? []).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
))}
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>} {member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>} {member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>} {member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
@@ -1324,7 +1615,7 @@ export function CombatScreen({
{status === 'upgrade-choice' && ( {status === 'upgrade-choice' && (
<div className="result-screen"> <div className="result-screen">
<div> <div className="pvp-upgrade-dialog pve-upgrade-dialog">
<p className="eyebrow"> <p className="eyebrow">
{encounter.isBoss {encounter.isBoss
? `Roguelike Stage ${roguelikeStage} Complete` ? `Roguelike Stage ${roguelikeStage} Complete`
@@ -1332,6 +1623,9 @@ export function CombatScreen({
</p> </p>
<h2>Choose Upgrade</h2> <h2>Choose Upgrade</h2>
<p>Pick one upgrade before the next fight.</p> <p>Pick one upgrade before the next fight.</p>
<div className="pvp-choice-columns">
<div>
<strong>Run Buff</strong>
<div className="upgrade-choice-grid"> <div className="upgrade-choice-grid">
{upgradeChoices.map((upgrade) => ( {upgradeChoices.map((upgrade) => (
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button"> <button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
@@ -1340,6 +1634,8 @@ export function CombatScreen({
</button> </button>
))} ))}
</div> </div>
</div>
</div>
{roguelikeUpgrades.length > 0 && ( {roguelikeUpgrades.length > 0 && (
<p className="roguelike-upgrade-list"> <p className="roguelike-upgrade-list">
Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)} Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}
@@ -1350,7 +1646,7 @@ export function CombatScreen({
</div> </div>
)} )}
{status !== 'playing' && status !== 'part-complete' && status !== 'upgrade-choice' && ( {status !== 'playing' && status !== 'part-complete' && status !== 'marathon-choice' && status !== 'upgrade-choice' && (
<div className="result-screen"> <div className="result-screen">
<div> <div>
<p className="eyebrow">{status === 'won' ? `${contentName} Complete` : 'Party Defeated'}</p> <p className="eyebrow">{status === 'won' ? `${contentName} Complete` : 'Party Defeated'}</p>
@@ -1465,12 +1761,43 @@ export function CombatScreen({
</div> </div>
</div> </div>
)} )}
{status === 'marathon-choice' && (
<div className="result-screen">
<div>
<p className="eyebrow">Marathon</p>
<h2>{encounter.enemyName} Defeated</h2>
<p>
{marathonBossesDefeated} boss{marathonBossesDefeated === 1 ? '' : 'es'} defeated.
Continue with current health and {gameClass.resourceName}, or end the hunt.
</p>
<button
onClick={() => {
const current = combatRef.current
setCombat({
...current,
enemyHealth: encounter.maxHealth * enemyCount,
elapsedTicks: 0,
})
setStatus('playing')
addLog(`Marathon continues. Another ${encounter.enemyName} appears.`, 'danger')
}}
type="button"
>
Continue Marathon
</button>
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
End
</button>
</div>
</div>
)}
{status === 'part-complete' && ( {status === 'part-complete' && (
<div className="result-screen"> <div className="result-screen">
<div> <div>
<p className="eyebrow">{sectionName} Complete</p> <p className="eyebrow">{sectionName} Complete</p>
<h2>{encounter.enemyName} Defeated</h2> <h2>{encounter.enemyName} Defeated</h2>
<p>Proceed to {sectionName} {currentPart + 1} or end the run?</p> <p>{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Run checkpoint complete.'}</p>
{canContinueAfterPart && (
<button <button
onClick={() => { onClick={() => {
const nextIndex = encounterIndex + 1 const nextIndex = encounterIndex + 1
@@ -1482,12 +1809,18 @@ export function CombatScreen({
health: clamp(member.health + 35, 0, member.maxHealth), health: clamp(member.health + 35, 0, member.maxHealth),
debuff: undefined, debuff: undefined,
debuffTicks: undefined, debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
})) }))
setEncounterIndex(nextIndex) setEncounterIndex(nextIndex)
setCombat({ setCombat({
...current, ...current,
party: recoveredParty, party: recoveredParty,
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth * enemyCount,
elapsedTicks: 0, elapsedTicks: 0,
}) })
setStatus('playing') setStatus('playing')
@@ -1497,6 +1830,7 @@ export function CombatScreen({
> >
Continue to {sectionName} {currentPart + 1} Continue to {sectionName} {currentPart + 1}
</button> </button>
)}
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button"> <button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
End Run End Run
</button> </button>
+42 -3
View File
@@ -4,6 +4,7 @@ import {
type CharacterProfile, type CharacterProfile,
type GameClass, type GameClass,
} from '../profile' } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { EquipmentScreen } from './EquipmentScreen' import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen' import { TalentScreen } from './TalentScreen'
@@ -14,7 +15,8 @@ type Props = {
} }
export function CustomizeScreen({ profile, onBack, onSaved }: Props) { export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class') const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
const { enabled: dualScreenEnabled } = useDualScreen()
const [classId, setClassId] = useState(profile.character.classId) const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots) const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0) const [selectedSlot, setSelectedSlot] = useState(0)
@@ -38,7 +40,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
function chooseClass(nextClass: GameClass) { function chooseClass(nextClass: GameClass) {
const starterAbilities = nextClass.spells const starterAbilities = nextClass.spells
.filter((ability) => ability.unlockLevel <= profile.character.level) .filter((ability) => ability.unlockLevel <= profile.character.level)
.slice(0, 5) .slice(0, 6)
.map((ability) => ability.id) .map((ability) => ability.id)
setClassId(nextClass.id) setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)]) setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
@@ -63,6 +65,29 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
) )
} }
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'class') return null
return {
mode: 'class',
title: 'Ability Library',
subtitle: gameClass.name,
summary: `Selected slot ${selectedSlot + 1}. ${message || 'Choose an ability for the active loadout.'}`,
items: gameClass.spells.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return {
glyph: locked ? 'L' : ability.glyph,
title: ability.name,
meta: locked ? `Level ${ability.unlockLevel}` : `${ability.cost} ${gameClass.resourceName}`,
detail: ability.description,
status: equipped ? 'Equipped' : locked ? 'Locked' : '',
}
}),
}
}, [activeTab, gameClass, message, profile.character.level, selectedSlot, slots])
useDualScreenWorkshopPublisher(classWorkshopState, dualScreenEnabled)
async function persistChanges() { async function persistChanges() {
saveScroll() saveScroll()
setSaving(true) setSaving(true)
@@ -80,7 +105,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
return ( return (
<section className="content-screen customize-screen"> <section className="content-screen customize-screen">
<div className="screen-heading"> <div className="screen-heading customize-heading">
<div> <div>
<p className="eyebrow">Character Workshop</p> <p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1> <h1>Customize Character</h1>
@@ -89,8 +114,10 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
</div> </div>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections"> <div className="customize-tabs" role="tablist" aria-label="Customize character sections">
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
{([ {([
{ key: 'equipment', label: 'Equipment' }, { key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' }, { key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' }, { key: 'class', label: 'Class' },
] as const).map((tab) => ( ] as const).map((tab) => (
@@ -110,6 +137,18 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
{activeTab === 'equipment' && ( {activeTab === 'equipment' && (
<EquipmentScreen <EquipmentScreen
embedded embedded
mode="equipment"
showModeTabs={false}
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'crafting' && (
<EquipmentScreen
embedded
mode="crafting"
showModeTabs={false}
profile={profile} profile={profile}
onUpdated={onSaved} onUpdated={onSaved}
/> />
+356 -133
View File
@@ -9,6 +9,7 @@ import {
type EquipmentSlot, type EquipmentSlot,
type Item, type Item,
} from '../profile' } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
const SLOT_LABELS: Record<EquipmentSlot, string> = { const SLOT_LABELS: Record<EquipmentSlot, string> = {
weapon: 'Weapon', weapon: 'Weapon',
@@ -24,16 +25,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,
@@ -49,36 +83,29 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [crafting, setCrafting] = useState(false) const [crafting, setCrafting] = useState(false)
const [upgrading, setUpgrading] = useState(false) const [upgrading, setUpgrading] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false) const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment') const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
const [inventoryPage, setInventoryPage] = useState(0) const [inventoryPage, setInventoryPage] = useState(0)
const [recipePage, setRecipePage] = useState(0) const [recipePage, setRecipePage] = useState(0)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
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 const selectedRecipeRequiresUpgrade = selectedRecipe
? profile.craftingRecipes.some((recipe) => ? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
recipe.sourceEncounterId === selectedRecipe.sourceEncounterId
&& recipe.item.slot === selectedRecipe.item.slot
&& recipe.item.itemLevel < selectedRecipe.item.itemLevel,
)
: false : false
const selectedItemRecipe = selectedItem const selectedItemRecipe = selectedItem
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id) ? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
: undefined : undefined
const upgradeRecipe = selectedItem && selectedItemRecipe const upgradeRecipe = selectedItem && selectedItemRecipe
? profile.craftingRecipes ? selectUpgradeRecipe(profile.gearUpgradePaths ?? [], profile.craftingRecipes, selectedItem)
.filter((recipe) =>
recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId
&& recipe.item.slot === selectedItem.slot
&& recipe.item.itemLevel > selectedItem.itemLevel,
)
.sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0]
: undefined : undefined
const equippedBySlot = useMemo( const equippedBySlot = useMemo(
() => new Map( () => new Map(
@@ -113,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)
@@ -126,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),
@@ -147,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
} }
@@ -227,73 +283,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
} }
} }
const content = ( function renderEquipmentActions() {
if (!selectedItem) {
return <p>Select an item to inspect it.</p>
}
if (selectedItem.slot === 'component') {
return <p className="component-note">Used in crafting.</p>
}
return (
<> <>
{!embedded && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Loadout</p>
<h1>Equipment</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="gear-summary">
<div className="gear-character">
<span style={{ borderColor: profile.character.themeColor, color: profile.character.themeColor }}>
{profile.character.className[0]}
</span>
<div>
<p className="eyebrow">{profile.character.className}</p>
<h2>{profile.character.name}</h2>
</div>
</div>
<GearStat value={profile.gearStats.averageItemLevel.toFixed(1)} label="Average Item Level" />
<GearStat value={`+${profile.gearStats.healingPower}`} label="Healing Power" />
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div>
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
{equipmentTab === 'equipment' ? (
<>
<section className="item-comparison">
{selectedItem ? (
selectedItem.slot === 'component' ? (
<>
<ItemDetail title="Crafting Component" item={selectedItem} />
<div className="equip-action">
<p className="component-note">Used in crafting.</p>
</div>
</>
) : (
<>
<ItemDetail title={selectedItem.equipped ? 'Selected Equipment' : 'Inventory Item'} item={selectedItem} />
<div className="comparison-arrow">vs</div>
{comparisonItem && comparisonItem.id !== selectedItem.id ? (
<ItemDetail title="Currently Equipped" item={comparisonItem} />
) : (
<div className="item-detail empty-comparison">
<p className="eyebrow">Comparison</p>
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div>
)}
<div className="equip-action">
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} /> <ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button <button
className="primary-button" className="primary-button"
@@ -327,7 +325,166 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
: 'Break Down'} : 'Break Down'}
</button> </button>
)} )}
</>
)
}
const workshopState = useMemo<DualScreenWorkshopState>(() => {
if (equipmentTab === 'crafting') {
if (!selectedRecipe) {
return {
mode: 'crafting',
title: 'Craft Output',
subtitle: 'No recipe selected',
items: [],
}
}
return {
mode: 'crafting',
title: selectedRecipe.item.name,
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
summary: selectedRecipe.item.description,
items: [
{
glyph: selectedRecipe.item.glyph,
title: 'Craft Output',
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
},
...selectedRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Item Level ${component.item.itemLevel}`,
status: `${component.owned}/${component.quantity}`,
})),
],
}
}
if (!selectedItem) {
return {
mode: 'equipment',
title: 'Equipment Detail',
subtitle: 'No item selected',
items: [],
}
}
return {
mode: 'equipment',
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
summary: selectedItem.description,
items: selectedItem.slot === 'component'
? [{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `Owned: ${selectedItem.quantity}`,
status: 'Component',
}]
: [
{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
},
...(comparisonItem && comparisonItem.id !== selectedItem.id
? [{
glyph: comparisonItem.glyph,
title: comparisonItem.name,
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
status: 'Currently Equipped',
}]
: [{
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
status: 'Comparison',
}]),
...(upgradeRecipe
? [
{
glyph: upgradeRecipe.item.glyph,
title: `Upgrade to ${upgradeRecipe.item.name}`,
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
},
...upgradeRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Required for upgrade`,
status: `${component.owned}/${component.quantity}`,
})),
]
: []),
],
}
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
const content = (
<>
{!embedded && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Loadout</p>
<h1>Equipment</h1>
</div> </div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="gear-summary">
<div className="gear-character">
<span style={{ borderColor: profile.character.themeColor, color: profile.character.themeColor }}>
{profile.character.className[0]}
</span>
<div>
<p className="eyebrow">{profile.character.className}</p>
<h2>{profile.character.name}</h2>
</div>
</div>
<GearStat value={profile.gearStats.averageItemLevel.toFixed(1)} label="Average Item Level" />
<GearStat value={`+${profile.gearStats.healingPower}`} label="Healing Power" />
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div>
{showModeTabs && (
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
)}
{equipmentTab === 'equipment' ? (
<>
<section className="item-comparison">
{selectedItem ? (
selectedItem.slot === 'component' ? (
<>
<ItemDetail title="Crafting Component" item={selectedItem} />
</>
) : (
<>
<ItemDetail title={selectedItem.equipped ? 'Selected Equipment' : 'Inventory Item'} item={selectedItem} />
<div className="comparison-arrow">vs</div>
{comparisonItem && comparisonItem.id !== selectedItem.id ? (
<ItemDetail title="Currently Equipped" item={comparisonItem} />
) : (
<div className="item-detail empty-comparison">
<p className="eyebrow">Comparison</p>
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div>
)}
</> </>
) )
) : ( ) : (
@@ -335,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" />
@@ -430,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
@@ -482,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}`}
@@ -494,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'}
@@ -510,22 +738,17 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
</div> </div>
))} ))}
</div> </div>
<button
className="primary-button"
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : '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>
@@ -561,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>
) )
+241 -56
View File
@@ -1,5 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { INITIAL_PARTY, RAID_PARTY, type CombatLogEntry, type PartyMember, type Spell } from '../game' import {
INITIAL_PARTY,
RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
groupHealTargets,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry,
type PartyMember,
type Spell,
} from '../game'
import { completeRoguelike, type DungeonReward } from '../profile' import { completeRoguelike, type DungeonReward } from '../profile'
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile' import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository' import type { GameMode } from '../gameRepository'
@@ -34,7 +44,7 @@ type PvpEncounter = DungeonEncounter & {
sourceEncounterId?: number 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 =
@@ -78,6 +88,17 @@ type FloatingCombatText = {
value: number value: number
} }
type PvpRunSummary = {
bossesKilled: number
experienceGained: number
previousLevel: number | null
newLevel: number | null
levelsGained: number
talentPointsGained: number
unlockedAbilities: DungeonReward['unlockedAbilities']
loot: Array<NonNullable<DungeonReward['bonusItem']>>
}
const BOSS_MECHANICS: BossMechanic[] = [ const BOSS_MECHANICS: BossMechanic[] = [
'party-pulse', 'party-pulse',
'searing-mark', 'searing-mark',
@@ -119,6 +140,19 @@ function formatEffectTime(ticks: number) {
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s` return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
} }
function createEmptyPvpRunSummary(): PvpRunSummary {
return {
bossesKilled: 0,
experienceGained: 0,
previousLevel: null,
newLevel: null,
levelsGained: 0,
talentPointsGained: 0,
unlockedAbilities: [],
loot: [],
}
}
function buffStacks<T extends string>(items: T[], id: T) { function buffStacks<T extends string>(items: T[], id: T) {
return items.filter((item) => item === id).length return items.filter((item) => item === id).length
} }
@@ -130,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 [
{ {
@@ -159,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 [
{ {
@@ -217,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[]) {
@@ -300,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')) {
@@ -374,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(
@@ -411,11 +445,14 @@ export function PvPRoguelikeScreen({
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource)) const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource)) const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
const [selectedId, setSelectedId] = useState(partyTemplate[0].id) const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const selectedIdRef = useRef(partyTemplate[0].id)
const [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[]>([])
@@ -433,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]
@@ -445,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)
@@ -459,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))
}, []) }, [])
@@ -473,11 +526,16 @@ export function PvPRoguelikeScreen({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (queuedMatchRef.current) return
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType) const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
setCheckpointStage(loadedCheckpoint) setCheckpointStage(loadedCheckpoint)
setStartStage(loadedCheckpoint) setStartStage(loadedCheckpoint)
}, [contentType, profile.character.id]) }, [contentType, profile.character.id])
useEffect(() => {
encounterPoolRef.current = encounterPool
}, [encounterPool])
const awardBossReward = useCallback((encounterIndexValue: number) => { const awardBossReward = useCallback((encounterIndexValue: number) => {
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
bossRewardClaimedRef.current.add(encounterIndexValue) bossRewardClaimedRef.current.add(encounterIndexValue)
@@ -497,6 +555,20 @@ export function PvPRoguelikeScreen({
) )
.then((result) => { .then((result) => {
setReward(result) setReward(result)
setRunSummary((current) => {
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
return {
bossesKilled: current.bossesKilled + 1,
experienceGained: current.experienceGained + result.experienceGained,
previousLevel: current.previousLevel ?? result.previousLevel,
newLevel: result.newLevel,
levelsGained: current.levelsGained + result.levelsGained,
talentPointsGained: current.talentPointsGained + result.talentPointsGained,
unlockedAbilities: Array.from(unlockedById.values()),
loot: result.bonusItem ? [...current.loot, result.bonusItem] : current.loot,
}
})
onProfileUpdated(result.profile) onProfileUpdated(result.profile)
if (result.bonusItem) { if (result.bonusItem) {
addLog( addLog(
@@ -532,8 +604,9 @@ export function PvPRoguelikeScreen({
: null) : null)
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
useEffect(() => { const startMatch = useCallback((nextStartStage?: number) => {
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType) const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
const firstEncounter = firstSegment[0] const firstEncounter = firstSegment[0]
const basePlayer = starterSide(partyTemplate, maxResource) const basePlayer = starterSide(partyTemplate, maxResource)
const baseCpu = starterSide(cpuPartyTemplate, maxResource) const baseCpu = starterSide(cpuPartyTemplate, maxResource)
@@ -543,15 +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(startStage) setCheckpointStage(matchStartStage)
setStartStage(matchStartStage)
setStage(matchStartStage)
setElapsedTicks(0) setElapsedTicks(0)
setStatus('queueing') setStatus('queueing')
setPlayerSide(basePlayer) setPlayerSide(basePlayer)
setCpuSide(baseCpu) setCpuSide(baseCpu)
setSelectedId(partyTemplate[0].id) setSelectedTargetId(partyTemplate[0].id)
setPlayerBuffChoices([]) setPlayerBuffChoices([])
setPlayerDebuffChoices([]) setPlayerDebuffChoices([])
setSelectedBuff(null) setSelectedBuff(null)
@@ -560,6 +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([])
@@ -569,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 at stage ${startStage}.`) setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
setCpuDifficulty(randomCpu) setCpuDifficulty(randomCpu)
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`, tone: 'system' }]) setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
setStatus('playing') setStatus('playing')
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system') addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
}, 500) }, 500)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
} }
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`) setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }]) setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const randomCpu = randomCpuDifficulty() const randomCpu = randomCpuDifficulty()
setCpuDifficulty(randomCpu) setCpuDifficulty(randomCpu)
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`) setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
setStatus('playing') setStatus('playing')
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system') addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
}, 1400) }, 1400)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage]) }, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
useEffect(() => startMatch(), [startMatch])
const applySpell = useCallback(( const applySpell = useCallback((
current: SideState, current: SideState,
@@ -607,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') {
@@ -626,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,
@@ -652,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,
} }
}) })
@@ -670,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)
@@ -736,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
@@ -790,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
@@ -801,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
@@ -842,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))
@@ -895,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, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, stage, 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
@@ -955,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
@@ -1007,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
@@ -1036,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
@@ -1081,6 +1232,7 @@ export function PvPRoguelikeScreen({
directPartyTargeting, directPartyTargeting,
paused, paused,
targetGroup, targetGroup,
speedMultiplier,
}), [ }), [
bindings, bindings,
controllerIconStyle, controllerIconStyle,
@@ -1105,6 +1257,7 @@ export function PvPRoguelikeScreen({
playerSide.party, playerSide.party,
playerSide.resource, playerSide.resource,
selectedId, selectedId,
speedMultiplier,
stage, stage,
starterSpells, starterSpells,
status, status,
@@ -1128,7 +1281,7 @@ export function PvPRoguelikeScreen({
{dualScreenEnabled && status !== 'queueing' && ( {dualScreenEnabled && status !== 'queueing' && (
<DualScreenTopCombat <DualScreenTopCombat
state={dualScreenState} state={dualScreenState}
onSelectTarget={setSelectedId} onSelectTarget={setSelectedTargetId}
/> />
)} )}
@@ -1143,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>
@@ -1152,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">
@@ -1351,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 && (
@@ -1392,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>
+185 -97
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
allocateTalent, allocateTalent,
resetTalents, resetTalents,
type CharacterProfile, type CharacterProfile,
type Talent, type Talent,
} from '../profile' } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
type Props = { type Props = {
profile: CharacterProfile profile: CharacterProfile
@@ -13,199 +14,286 @@ type Props = {
embedded?: boolean embedded?: boolean
} }
const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const
const EFFECT_CLASS_ID = 1
const EFFECTS_PER_PAGE = 8
const EFFECT_SOURCE_LABELS: Record<string, string> = {
mend: 'Mend',
radiance: 'Radiance',
shield: 'Shield',
}
function effectSource(effectType: string) {
if (effectType.startsWith('mend_')) return 'mend'
if (effectType.startsWith('radiance_')) return 'radiance'
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'shield'
return effectType
}
function effectCapacity(level: number) {
return EFFECT_SLOT_LEVELS.filter((slotLevel) => level >= slotLevel).length
}
function activeEffects(talents: Talent[]) {
return talents.filter((talent) => talent.rank > 0)
}
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) { export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
const { enabled: dualScreenEnabled } = useDualScreen()
const [busyTalentId, setBusyTalentId] = useState<number | null>(null) const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
const [talentPage, setTalentPage] = useState(0)
const [resetting, setResetting] = useState(false) const [resetting, setResetting] = useState(false)
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
const [effectPage, setEffectPage] = useState(0)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0) const scrollRef = useRef<number>(0)
const gameClass = profile.classes.find( const gameClass = profile.classes.find(
(candidate) => candidate.id === profile.character.classId, (candidate) => candidate.id === profile.character.classId,
)! )!
const classPointsSpent = gameClass.talents.reduce( const isEffectClass = gameClass.id === EFFECT_CLASS_ID
(total, talent) => total + talent.rank, const capacity = isEffectClass ? effectCapacity(profile.character.level) : 0
0, const selectedEffects = activeEffects(gameClass.talents)
const selectedTalent = gameClass.talents.find((talent) => talent.id === selectedTalentId)
?? selectedEffects[0]
?? gameClass.talents[0]
?? null
const effectPageCount = Math.max(1, Math.ceil(gameClass.talents.length / EFFECTS_PER_PAGE))
const visibleTalents = gameClass.talents.slice(
effectPage * EFFECTS_PER_PAGE,
effectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE,
) )
const tiers = Array.from(
new Set(gameClass.talents.map((talent) => talent.tier)),
).sort((a, b) => a - b)
const tierPages = Array.from(
{ length: Math.ceil(tiers.length / 2) },
(_, index) => tiers.slice(index * 2, index * 2 + 2),
)
const visibleTiers = tierPages[talentPage] ?? tierPages[0] ?? []
useEffect(() => { useEffect(() => {
window.scrollTo(0, scrollRef.current) window.scrollTo(0, scrollRef.current)
}, [profile]) }, [profile])
useEffect(() => {
if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return
setSelectedTalentId(selectedTalent?.id ?? null)
}, [gameClass.talents, selectedTalent?.id, selectedTalentId])
useEffect(() => {
setEffectPage((page) => Math.min(page, effectPageCount - 1))
}, [effectPageCount])
function saveScroll() { function saveScroll() {
scrollRef.current = window.scrollY scrollRef.current = window.scrollY
} }
function lowerTierPoints(talent: Talent) {
return gameClass.talents
.filter((candidate) => candidate.tier < talent.tier)
.reduce((total, candidate) => total + candidate.rank, 0)
}
function lockReason(talent: Talent) { function lockReason(talent: Talent) {
if (talent.rank >= talent.maxRank) return 'Maximum rank' if (!isEffectClass) return 'Coming soon'
if (talent.rank > 0) return ''
const requiredTierPoints = (talent.tier - 1) * 5 const source = effectSource(talent.effectType)
if (lowerTierPoints(talent) < requiredTierPoints) { const sourceConflict = selectedEffects.find((effect) => effectSource(effect.effectType) === source)
return `Requires ${requiredTierPoints} earlier-tier points` if (sourceConflict) return `${EFFECT_SOURCE_LABELS[source] ?? source} already selected`
if (capacity <= 0) return 'Unlocks at level 5'
if (selectedEffects.length >= capacity) {
return `Active slots full (${capacity}/${capacity})`
} }
if (talent.prerequisiteTalentId) {
const prerequisite = gameClass.talents.find(
(candidate) => candidate.id === talent.prerequisiteTalentId,
)
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
return `Requires ${talent.prerequisiteName} rank ${talent.prerequisiteRank}`
}
}
if (profile.character.talentPoints <= 0) return 'No points available'
return '' return ''
} }
async function purchaseRank(talent: Talent) { async function toggleEffect(talent: Talent) {
saveScroll() saveScroll()
setBusyTalentId(talent.id) setBusyTalentId(talent.id)
setMessage('') setMessage('')
try { try {
const updated = await allocateTalent(talent.id) const updated = await allocateTalent(talent.id)
onUpdated(updated) onUpdated(updated)
setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`) setSelectedTalentId(talent.id)
setMessage(talent.rank > 0 ? `${talent.name} removed.` : `${talent.name} activated.`)
} catch (reason) { } catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.') setMessage(reason instanceof Error ? reason.message : 'Unable to update spell effect.')
} finally { } finally {
setBusyTalentId(null) setBusyTalentId(null)
} }
} }
async function refundTree() { async function clearEffects() {
saveScroll() saveScroll()
setResetting(true) setResetting(true)
setMessage('') setMessage('')
try { try {
const updated = await resetTalents() const updated = await resetTalents()
onUpdated(updated) onUpdated(updated)
setMessage('All points in this talent tree were refunded.') setMessage('Spell effects cleared.')
} catch (reason) { } catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.') setMessage(reason instanceof Error ? reason.message : 'Unable to clear spell effects.')
} finally { } finally {
setResetting(false) setResetting(false)
} }
} }
const workshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (!isEffectClass) return null
return {
mode: 'talents',
title: 'Spell Effects',
subtitle: `${selectedEffects.length}/${capacity} active`,
summary: selectedTalent
? `${selectedTalent.name}: ${selectedTalent.description}`
: 'Choose effects to modify your spells.',
items: gameClass.talents.map((talent) => ({
glyph: talent.glyph,
title: talent.name,
meta: talent.rank > 0 ? 'Active' : lockReason(talent) || 'Available',
detail: talent.description,
status: talent.rank > 0 ? 'Selected' : '',
})),
}
}, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent])
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
const content = ( const content = (
<> <>
{!embedded && ( {!embedded && (
<div className="screen-heading"> <div className="screen-heading">
<div> <div>
<p className="eyebrow">Character Growth</p> <p className="eyebrow">Character Growth</p>
<h1>Talents</h1> <h1>Spell Effects</h1>
</div> </div>
<button className="back-button" onClick={onBack} type="button">Back</button> <button className="back-button" onClick={onBack} type="button">Back</button>
</div> </div>
)} )}
<div className="talent-toolbar"> <div className="talent-toolbar spell-effect-toolbar">
<div className="talent-class-summary"> <div className="talent-class-summary">
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}> <span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
{gameClass.name[0]} {gameClass.name[0]}
</span> </span>
<div> <div>
<p className="eyebrow">{gameClass.name} Tree</p> <p className="eyebrow">{gameClass.name} Effects</p>
<h2>Shape Your Healing Style</h2> <h2>Modify Your Spells</h2>
</div> </div>
</div> </div>
<div className="talent-points"> <div className="talent-points">
<strong>{profile.character.talentPoints}</strong> <strong>{selectedEffects.length}/{capacity}</strong>
<span>Available</span> <span>Active</span>
<small>{classPointsSpent} spent in this tree</small> <small>Slots unlock at levels 5, 10, 15, 20</small>
</div> </div>
</div> </div>
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages"> {!isEffectClass ? (
{tierPages.map((pageTiers, index) => ( <div className="talent-empty-state">
<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>
</> </>
+113 -3
View File
@@ -42,7 +42,7 @@ export type DualScreenCombatState = {
partySize: number partySize: number
selectedId: string selectedId: string
log: CombatLogEntry[] log: CombatLogEntry[]
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice' status: 'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'
resource: number resource: number
maxResource: number maxResource: number
resourceName: string resourceName: string
@@ -54,14 +54,31 @@ export type DualScreenCombatState = {
directPartyTargeting: boolean directPartyTargeting: boolean
paused: boolean paused: boolean
targetGroup: 0 | 1 | 2 targetGroup: 0 | 1 | 2
speedMultiplier: 1 | 2
}
export type DualScreenWorkshopState = {
mode: 'class' | 'equipment' | 'crafting' | 'talents'
title: string
subtitle: string
summary?: string
items: Array<{
glyph?: string
title: string
meta?: string
detail?: string
status?: string
}>
} }
type DualScreenMessage = type DualScreenMessage =
| { type: 'combat-state'; state: DualScreenCombatState } | { type: 'combat-state'; state: DualScreenCombatState }
| { type: 'workshop-state'; state: DualScreenWorkshopState }
| { type: 'companion-ready' } | { type: 'companion-ready' }
| { type: 'companion-heartbeat' } | { type: 'companion-heartbeat' }
| { type: 'control-action'; action: InputAction } | { type: 'control-action'; action: InputAction }
| { type: 'combat-ended' } | { type: 'combat-ended' }
| { type: 'workshop-ended' }
type DualScreenContextValue = { type DualScreenContextValue = {
enabled: boolean enabled: boolean
@@ -102,6 +119,13 @@ function loadRecentSnapshot() {
} }
} }
function memberHotEffects(member: PartyMember) {
if (member.hotEffects?.length) return member.hotEffects
return member.hotTicks > 0
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
: []
}
export function DualScreenProvider({ children }: { children: ReactNode }) { export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState( const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true', () => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -280,16 +304,64 @@ export function useDualScreenPublisher(
}, [enabled, state]) }, [enabled, state])
} }
export function useDualScreenWorkshopPublisher(
state: DualScreenWorkshopState | null,
enabled: boolean,
) {
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
if (!channel) return
const publish = () => {
if (stateRef.current) {
channel.postMessage({
type: 'workshop-state',
state: stateRef.current,
} satisfies DualScreenMessage)
}
}
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
}
publish()
return () => {
channel.postMessage({ type: 'workshop-ended' } satisfies DualScreenMessage)
channel.close()
}
}, [enabled, state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
channel?.postMessage({ type: 'workshop-state', state } satisfies DualScreenMessage)
channel?.close()
}, [enabled, state])
}
export function DualScreenBottomDisplay() { export function DualScreenBottomDisplay() {
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot) const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
useEffect(() => { useEffect(() => {
const channel = createChannel() const channel = createChannel()
if (!channel) return if (!channel) return
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage) const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => { channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'combat-state') setState(event.data.state) if (event.data.type === 'combat-state') {
setState(event.data.state)
setWorkshopState(null)
}
if (event.data.type === 'workshop-state') {
setWorkshopState(event.data.state)
setState(null)
}
if (event.data.type === 'combat-ended') setState(null) if (event.data.type === 'combat-ended') setState(null)
if (event.data.type === 'workshop-ended') setWorkshopState(null)
} }
announce() announce()
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
@@ -307,6 +379,40 @@ export function DualScreenBottomDisplay() {
channel?.close() channel?.close()
} }
if (!state && workshopState) {
return (
<main className="dual-bottom-display workshop-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{workshopState.mode}</p>
<h1>{workshopState.title}</h1>
</div>
<div className="dual-controls-progress">
<span>{workshopState.subtitle}</span>
</div>
</header>
{workshopState.summary && (
<section className="workshop-bottom-summary">
{workshopState.summary}
</section>
)}
<section className="workshop-bottom-grid">
{workshopState.items.map((item, index) => (
<article key={`${item.title}-${index}`}>
{item.glyph && <span>{item.glyph}</span>}
<div>
<strong>{item.title}</strong>
{item.meta && <small>{item.meta}</small>}
{item.detail && <p>{item.detail}</p>}
</div>
{item.status && <i>{item.status}</i>}
</article>
))}
</section>
</main>
)
}
if (!state) { if (!state) {
return ( return (
<main className="dual-bottom-display dual-bottom-waiting"> <main className="dual-bottom-display dual-bottom-waiting">
@@ -340,6 +446,7 @@ export function DualScreenBottomDisplay() {
</div> </div>
<div className="dual-controls-mana"> <div className="dual-controls-mana">
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span> <span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"> <div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} /> <span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
</div> </div>
@@ -475,6 +582,7 @@ export function DualScreenTopCombat({
return ( return (
<button <button
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`} className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
data-party-member-id={member.id}
key={member.id} key={member.id}
onClick={() => onSelectTarget(member.id)} onClick={() => onSelectTarget(member.id)}
type="button" type="button"
@@ -500,7 +608,9 @@ export function DualScreenTopCombat({
</div> </div>
)} )}
<div className="member-effects"> <div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew</span>} {memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label}</span>
))}
{member.debuff && <span className="debuff">{member.debuff}</span>} {member.debuff && <span className="debuff">{member.debuff}</span>}
</div> </div>
</button> </button>
+45 -2
View File
@@ -8,6 +8,20 @@ export type PartyMember = {
maxHealth: number maxHealth: number
shield: number shield: number
hotTicks: number hotTicks: number
hotEffects?: Array<{
id: string
spellId: string
label: string
ticks: number
power: number
}>
bounceHeals?: Array<{
id: string
label: string
charges: number
power: number
}>
damageReductionTicks?: number
debuff?: string debuff?: string
debuffTicks?: number debuffTicks?: number
poisonStacks?: number poisonStacks?: number
@@ -24,7 +38,8 @@ export type Spell = {
cooldown: number cooldown: number
power: number power: number
glyph: string glyph: string
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' | 'damage_reduction' | 'bounce_heal'
effectType?: string
} }
export type Encounter = { export type Encounter = {
@@ -44,6 +59,9 @@ export type CombatLogEntry = {
tone: 'system' | 'heal' | 'danger' | 'loot' tone: 'system' | 'heal' | 'danger' | 'loot'
} }
export const TANKLESS_DAMAGE_MULTIPLIER = 1.35
export const DEFAULT_GROUP_HEAL_TARGETS = 4
export const INITIAL_PARTY: PartyMember[] = [ export const INITIAL_PARTY: PartyMember[] = [
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 }, { id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 }, { id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
@@ -101,7 +119,7 @@ export const SPELLS: Spell[] = [
id: 'radiance', id: 'radiance',
key: '3', key: '3',
name: 'Radiance', name: 'Radiance',
description: 'Restores health to every living party member.', description: 'Restores health to up to 4 injured party members.',
cost: 12, cost: 12,
cooldown: 8, cooldown: 8,
power: 18, power: 18,
@@ -164,3 +182,28 @@ export const ENCOUNTERS: Encounter[] = [
isBoss: true, isBoss: true,
}, },
] ]
export function partyDamageOutput(party: PartyMember[], baseDamage: number) {
const livingCount = party.filter((member) => member.health > 0).length
return Math.round(baseDamage * (livingCount / Math.max(1, party.length)))
}
export function tankPressureTargets(party: PartyMember[]) {
const living = party.filter((member) => member.health > 0)
const tanks = living.filter((member) => member.role === 'Tank')
if (tanks.length > 0) return { targets: tanks, multiplier: 1 }
const damageDealer = living
.filter((member) => member.role === 'Damage')
.sort((left, right) => right.health - left.health)[0]
return {
targets: damageDealer ? [damageDealer] : [],
multiplier: TANKLESS_DAMAGE_MULTIPLIER,
}
}
export function groupHealTargets(party: PartyMember[], targetCount = DEFAULT_GROUP_HEAL_TARGETS) {
return party
.filter((member) => member.health > 0)
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))
.slice(0, targetCount)
}
+236 -39
View File
@@ -1,4 +1,5 @@
import starterProfile from './offline-starter-profile.json' import starterProfile from './offline-starter-profile.json'
import { bundledCatalogHash } from './offline-catalog-meta'
import type { import type {
Account, Account,
AuthSession, AuthSession,
@@ -26,6 +27,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,
@@ -69,6 +71,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>
} }
@@ -80,6 +83,12 @@ type OnlineCache = {
dirty: boolean dirty: boolean
} }
type CatalogCache = {
version: 1
hash: string
profile: CharacterProfile
}
export type CloudSyncStatus = { export type CloudSyncStatus = {
available: boolean available: boolean
dirty: boolean dirty: boolean
@@ -100,8 +109,11 @@ 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 catalogCacheKey = 'chronicle.catalog.v1'
const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
const authTokenKey = 'chronicle.authToken.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)
@@ -146,7 +158,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) : [],
} }
@@ -157,17 +169,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 {
@@ -177,12 +213,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
} }
@@ -254,8 +290,42 @@ function clearOnlineCache() {
localStorage.removeItem(onlineCacheKey) localStorage.removeItem(onlineCacheKey)
} }
function bundledCatalog(): CatalogCache {
return {
version: 1,
hash: bundledCatalogHash,
profile: starterProfile as CharacterProfile,
}
}
function readCatalogCache(): CatalogCache | null {
if (localStorage.getItem(catalogBundleKey) !== bundledCatalogHash) {
localStorage.removeItem(catalogCacheKey)
localStorage.setItem(catalogBundleKey, bundledCatalogHash)
return null
}
const serialized = localStorage.getItem(catalogCacheKey)
if (!serialized) return null
try {
const raw = JSON.parse(serialized) as CatalogCache
if (raw.version !== 1 || typeof raw.hash !== 'string' || !raw.profile) return null
return raw
} catch {
return null
}
}
function writeCatalogCache(cache: CatalogCache) {
localStorage.setItem(catalogBundleKey, bundledCatalogHash)
localStorage.setItem(catalogCacheKey, JSON.stringify(cache))
}
function activeCatalog(): CatalogCache {
return readCatalogCache() ?? bundledCatalog()
}
function buildProfile(save: OfflineSave): CharacterProfile { function buildProfile(save: OfflineSave): CharacterProfile {
const static_ = clone(starterProfile) as CharacterProfile const static_ = clone(activeCatalog().profile)
const cd = save.characters[save.activeClassId] const cd = save.characters[save.activeClassId]
const gameClass = static_.classes.find((c) => c.id === save.activeClassId)! const gameClass = static_.classes.find((c) => c.id === save.activeClassId)!
@@ -291,6 +361,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_
} }
@@ -333,10 +407,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),
} }
}) })
} }
@@ -355,15 +432,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
@@ -374,7 +494,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
} }
@@ -382,15 +503,25 @@ 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
@@ -425,7 +556,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),
} }
@@ -435,6 +566,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 ?? {}),
} }
@@ -566,6 +700,23 @@ function isNetworkError(reason: unknown): reason is NetworkError {
return reason instanceof Error && Boolean((reason as NetworkError).network) return reason instanceof Error && Boolean((reason as NetworkError).network)
} }
async function loadServerCatalog(): Promise<CatalogCache> {
return requestJson('/api/catalog')
}
async function refreshCatalogFromServer(): Promise<CatalogCache | null> {
try {
const catalog = await loadServerCatalog()
if (catalog.version !== 1 || !catalog.hash || !catalog.profile) return null
if (catalog.hash !== activeCatalog().hash || !readCatalogCache()) {
writeCatalogCache(catalog)
}
return catalog
} catch {
return null
}
}
function cachedOnlineSession(): AuthSession | null { function cachedOnlineSession(): AuthSession | null {
const cache = readOnlineCache() const cache = readOnlineCache()
if (!cache) return null if (!cache) return null
@@ -607,6 +758,7 @@ async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: Charact
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> { async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
const cache = readOnlineCache() const cache = readOnlineCache()
if (session.token) writeAuthToken(session.token) if (session.token) writeAuthToken(session.token)
await refreshCatalogFromServer()
if (!session.account || !session.profile) { if (!session.account || !session.profile) {
if (session.account && cache?.account.id === session.account.id) { if (session.account && cache?.account.id === session.account.id) {
return { return {
@@ -718,7 +870,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,
@@ -727,6 +879,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(
@@ -756,16 +909,16 @@ const serverRepository: GameRepository = {
} }
function emptyCharacterData(classId: number): CharacterData { function emptyCharacterData(classId: number): CharacterData {
const static_ = clone(starterProfile) as CharacterProfile const static_ = clone(activeCatalog().profile)
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 inventory: Item[] = [] const inventory: Item[] = []
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,
@@ -809,8 +962,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.')
@@ -833,7 +985,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) {
@@ -859,8 +1011,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)
@@ -895,6 +1054,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) {
@@ -908,19 +1071,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, quantity: 1, duplicate, quantityAfter } bonusItem = { ...selected, quantity: rewardQuantity, duplicate, quantityAfter }
} }
} }
@@ -973,13 +1137,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
@@ -1043,6 +1213,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.')
} }
@@ -1085,10 +1283,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)
}, },
@@ -1163,18 +1363,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 = profile.craftingRecipes.some((candidate) => const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
candidate.sourceEncounterId === recipe.sourceEncounterId
&& candidate.item.slot === recipe.item.slot
&& candidate.item.itemLevel < recipe.item.itemLevel,
)
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.') 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
@@ -1196,21 +1394,17 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
if (item.slot === 'component') throw new Error('Components cannot be upgraded.') if (item.slot === 'component') throw new Error('Components cannot be upgraded.')
const currentRecipe = profile.craftingRecipes.find((recipe) => recipe.item.id === item.id) const currentRecipe = profile.craftingRecipes.find((recipe) => recipe.item.id === item.id)
const targetRecipe = currentRecipe const targetRecipe = currentRecipe
? profile.craftingRecipes ? selectUpgradeRecipe(profile.gearUpgradePaths ?? [], profile.craftingRecipes, item)
.filter((recipe) =>
recipe.sourceEncounterId === currentRecipe.sourceEncounterId
&& recipe.item.slot === item.slot
&& recipe.item.itemLevel > item.itemLevel,
)
.sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0]
: null : null
if (!targetRecipe) throw new Error('No upgrade is available for this item.') if (!targetRecipe) throw new Error('No upgrade is available for this item.')
if (targetRecipe.components.length === 0) throw new Error('That upgrade has no component requirements.')
const missing = targetRecipe.components.find((component) => component.owned < component.quantity) const missing = targetRecipe.components.find((component) => component.owned < component.quantity)
if (missing) { if (missing) {
throw new Error(`Need ${missing.quantity} ${missing.item.name} to upgrade this item.`) throw new Error(`Need ${missing.quantity} ${missing.item.name} to upgrade this item.`)
} }
for (const component of targetRecipe.components) { 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) 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.`) if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to upgrade this item.`)
owned.quantity -= component.quantity owned.quantity -= component.quantity
@@ -1363,7 +1557,7 @@ const cachedOnlineRepository: GameRepository = {
}, },
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,
@@ -1372,6 +1566,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(
@@ -1410,7 +1605,9 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
if (!cache) { if (!cache) {
throw new Error('No signed-in save is available for cloud sync.') throw new Error('No signed-in save is available for cloud sync.')
} }
await refreshCatalogFromServer()
const synced = await pushServerSyncSave(cache.save) const synced = await pushServerSyncSave(cache.save)
await refreshCatalogFromServer()
writeOnlineCache({ writeOnlineCache({
version: 1, version: 1,
account: cache.account, account: cache.account,
@@ -1418,7 +1615,7 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
dirty: false, dirty: false,
}) })
writeMode('online') writeMode('online')
return synced.profile return buildProfile(synced.save)
} }
export function selectOnlineMode() { export function selectOnlineMode() {
+18 -17
View File
@@ -35,6 +35,7 @@ export const INPUT_ACTIONS = [
'targetParty5', 'targetParty5',
'targetParty6', 'targetParty6',
'toggleTargetGroup', 'toggleTargetGroup',
'toggleSpeed',
'pause', 'pause',
] as const ] as const
@@ -63,6 +64,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
targetParty5: 'Target Party Member 5', targetParty5: 'Target Party Member 5',
targetParty6: 'Target Party Member 6', targetParty6: 'Target Party Member 6',
toggleTargetGroup: 'Switch Raid Target Group', toggleTargetGroup: 'Switch Raid Target Group',
toggleSpeed: 'Toggle 2x Speed',
pause: 'Pause Menu', pause: 'Pause Menu',
} }
@@ -89,6 +91,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty5: 'F5', targetParty5: 'F5',
targetParty6: 'F6', targetParty6: 'F6',
toggleTargetGroup: 'Tab', toggleTargetGroup: 'Tab',
toggleSpeed: 'Backquote',
pause: 'Escape', pause: 'Escape',
}, },
controller: { controller: {
@@ -111,8 +114,9 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty3: 'Button15', targetParty3: 'Button15',
targetParty4: 'Button13', targetParty4: 'Button13',
targetParty5: 'Button4', targetParty5: 'Button4',
targetParty6: 'Button11', targetParty6: 'Button10',
toggleTargetGroup: 'Button6', toggleTargetGroup: 'Button6',
toggleSpeed: 'Button11',
pause: 'Button9', pause: 'Button9',
}, },
} }
@@ -121,7 +125,6 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1' const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action' const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller' const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const COMBAT_TARGET_NAVIGATION_THROTTLE_MS = 220
type CaptureState = { type CaptureState = {
device: InputDevice device: InputDevice
@@ -146,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',
@@ -167,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,
@@ -277,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',
@@ -398,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
@@ -445,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 < COMBAT_TARGET_NAVIGATION_THROTTLE_MS) return
lastCombatNavigationRef.current = now
}
setLastDevice(device) setLastDevice(device)
document.documentElement.dataset.inputDevice = device document.documentElement.dataset.inputDevice = device
@@ -519,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',
+1
View File
@@ -0,0 +1 @@
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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[]
} }
@@ -319,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,
@@ -328,6 +337,7 @@ export async function completeDungeon(
completedPart, completedPart,
startPart, startPart,
partDurationSeconds, partDurationSeconds,
hardMode,
) )
} }