Compare commits

...
38 Commits
Author SHA1 Message Date
Warren H 547044892d Android build v1.1.21 2026-07-04 21:15:29 -04:00
Warren H 81d65bc381 Android build v1.1.20 2026-07-04 21:15:04 -04:00
Warren H bdae0007a1 Android build v1.1.19 2026-07-04 20:50:33 -04:00
Warren H c052b086f8 Android build v1.1.18 2026-07-04 19:31:58 -04:00
Warren H c2a14bac7d Android build v1.1.17 2026-07-04 18:07:50 -04:00
Warren H 0d269a6041 Android build v1.1.16 2026-07-04 17:41:45 -04:00
Warren H 99e97e5208 Android build v1.1.15 2026-07-04 12:43:59 -04:00
Warren H a849cd2c69 Android build v1.1.14 2026-07-03 12:58:53 -04:00
Warren H a121b2ef74 Android build v1.1.13 2026-07-03 01:01:32 -04:00
Warren H b7cb572631 Android build v1.1.12 2026-07-03 00:28:38 -04:00
Warren H c6e2c61d0a Android build v1.1.11 2026-07-02 23:03:52 -04:00
Warren H f00ad8655b Android build v1.1.10 2026-07-02 22:42:48 -04:00
Warren H 6560a049f2 Android build v1.1.9 2026-07-02 21:43:04 -04:00
Warren H 89bc1b6ab8 Android build v1.1.8 2026-07-02 14:11:18 -04:00
Warren H 5b8c32dc41 Android build v1.1.7 2026-06-30 14:23:45 -04:00
Warren H df6306fe7a Android build v1.1.6 2026-06-30 13:59:36 -04:00
Warren H f6d408f594 Android build v1.1.5 2026-06-30 13:30:33 -04:00
Warren H 1797154726 Android build v1.1.4 2026-06-29 23:06:37 -04:00
Warren H bbcc67a54a Android build v1.1.3 2026-06-29 22:55:53 -04:00
Warren H c6251167a5 Android build v1.1.2 2026-06-29 22:35:49 -04:00
Warren H cbe42b6164 Android build v1.0.60 stadium updated 2026-06-23 13:13:57 -04:00
Warren H c2888c287b Android build v1.0.59 stadium updated 2026-06-22 23:38:12 -04:00
Warren H 4703017832 Android build v1.0.58 stadium added 2026-06-22 23:15:50 -04:00
Warren H c0f2daccb1 Android build v1.0.57 2026-06-21 21:09:51 -04:00
Warren H abdf4cc654 Android build v1.0.56 2026-06-21 21:00:17 -04:00
Warren H 5449276521 Android build v1.0.55 2026-06-21 20:38:55 -04:00
Warren H 787e2bbae9 Android build v1.0.54 2026-06-21 20:22:12 -04:00
Warren H 421540c52b Android build v1.0.53 2026-06-21 20:07:26 -04:00
Warren H 1e24aecad8 Android build v1.0.52 2026-06-21 12:35:10 -04:00
Warren H c9fb28ab6d Android build v1.0.50 2026-06-21 12:34:35 -04:00
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
159 changed files with 27273 additions and 3639 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.
+43 -1
View File
@@ -1,9 +1,51 @@
# Project Notes # Project Notes
## Target Hardware: AYN Thor
- 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. - 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. - 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. - 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.
## Input Requirements
- Every game screen, menu, dialog, overlay, and gameplay interaction must be fully navigable by controller at all times.
- Any time a screen, menu, dialog, overlay, or gameplay interaction is added or changed, verify controller navigation still works for that surface before considering the work complete.
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
- Focus state must always be visible and predictable when controller navigation is active.
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
## Architecture Requirements
- Keep systems modular and scoped by responsibility.
- Prefer small reusable modules/components over large files with mixed concerns.
- Keep shared game logic independent from platform-specific web or mobile wrappers whenever practical.
- Apply game changes to both web version and mobile app version. - Apply game changes to both web version and mobile app version.
## I Want To Heal 1 vs I Want To Heal 2
- I Want To Heal 1 is the existing healer-first menu/combat game. Preserve its current progression, saves, inventories, collection logs, combat screens, and content unless the user explicitly asks to change IWT1.
- I Want To Heal 2 is the new 2D boss-arena version. Keep IWT2 code, content, save data, inventories, character levels, and collection logs separate from IWT1 so the two modes are not confused.
- The app should offer a game-select screen before the main menu: `I Want To Heal 1` launches the old version, and `I Want To Heal 2` launches the new 2D version.
- Both games must remain usable from the same Android APK and from `iwanttoheal.phenomrom.com`.
- Prefer an explicit folder split for IWT2, such as `src/modes/iwt2/`, with subfolders for screens, simulation, rendering, content, and save/repository code.
- Keep IWT2 simulation state independent from rendering. Arena movement, AI, boss attacks, collisions, damage, stun/knockdown, progression, inventory, and collection logs should live in pure TypeScript modules where practical.
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically.
- First IWT2 boss conversion target: Bulldrome. Bulldrome should have normal melee tank damage, a charge across the arena that damages and knocks down/stuns hit players for 0.75 seconds, and every 3rd charge should be followed by an AoE ground slam around the boss that also damages and knocks down/stuns hit players for 0.75 seconds.
- IWT2 starter party fantasy: player-controlled healer plus visible party members moving in the arena. Paladin tank holds aggro; ranger fires arrows; mage fires fireballs; rogue flanks; warrior fights in melee. Each class should have a distinct icon and color scheme.
## Performance Requirements
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
- Avoid unnecessary per-frame allocations, polling, timers, layout work, and re-renders.
- Keep assets sized appropriately for Thor displays; do not ship oversized textures, images, audio, or data blobs without need.
- Prefer event-driven updates and cached calculations for UI and game systems.
- Profile or measure when changing hot paths, rendering, animation, input handling, asset loading, or persistent state.
## Build Requirements
- User rebuilds app; do not rebuild APK unless explicitly requested.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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 61 versionCode 100
versionName "1.0.41" versionName "1.1.21"
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.
@@ -0,0 +1,51 @@
package com.warren.iwanttoheal;
import android.view.Display;
import android.view.Window;
import android.view.WindowManager;
final class AndroidDisplayPerformance {
private static final float TARGET_REFRESH_RATE = 60.0f;
private AndroidDisplayPerformance() {}
static void preferBatteryRefreshRate(Window window, Display display) {
if (window == null || display == null) return;
Display.Mode currentMode = display.getMode();
Display.Mode selectedMode = closestBatteryMode(display, currentMode);
if (selectedMode == null) return;
WindowManager.LayoutParams attributes = window.getAttributes();
if (attributes.preferredDisplayModeId == selectedMode.getModeId()) return;
attributes.preferredDisplayModeId = selectedMode.getModeId();
window.setAttributes(attributes);
}
private static Display.Mode closestBatteryMode(Display display, Display.Mode currentMode) {
Display.Mode selectedMode = null;
float selectedScore = Float.MAX_VALUE;
for (Display.Mode mode : display.getSupportedModes()) {
if (
currentMode != null
&& (
mode.getPhysicalWidth() != currentMode.getPhysicalWidth()
|| mode.getPhysicalHeight() != currentMode.getPhysicalHeight()
)
) {
continue;
}
float refreshRate = mode.getRefreshRate();
float score = Math.abs(refreshRate - TARGET_REFRESH_RATE);
if (refreshRate > TARGET_REFRESH_RATE + 0.5f) score += 1000.0f;
if (score < selectedScore) {
selectedMode = mode;
selectedScore = score;
}
}
return selectedMode;
}
}
@@ -1,25 +1,45 @@
package com.warren.iwanttoheal; package com.warren.iwanttoheal;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.SystemClock; import android.os.SystemClock;
import android.view.InputDevice;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View; import android.view.View;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import java.io.File; import java.io.File;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public abstract class ControllerBridgeActivity extends BridgeActivity { public abstract class ControllerBridgeActivity extends BridgeActivity {
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL"; public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
private static final long DPAD_THROTTLE_MS = 220; private static final String PREFS_NAME = "com.warren.iwanttoheal.performance";
private long lastDpadDispatchAt = 0; private static final String CACHE_CLEARED_VERSION_KEY = "webview_cache_cleared_version";
private static final float AXIS_DEAD_ZONE = 0.45f;
private static final long DPAD_THROTTLE_MS = 55;
private final long[] lastDpadDispatchAt = new long[16];
private final Set<String> activeMotionTokens = new HashSet<>();
private final Map<String, Long> lastMotionDispatchAt = new HashMap<>();
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
clearWebViewServiceWorkers(); boolean shouldClearWebViewCache = shouldClearWebViewCacheForCurrentVersion();
if (shouldClearWebViewCache) clearWebViewServiceWorkers();
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), getWindowManager().getDefaultDisplay());
if (bridge != null) { if (bridge != null) {
bridge.getWebView().clearCache(true); if (shouldClearWebViewCache) {
bridge.getWebView().clearCache(true);
markWebViewCacheClearedForCurrentVersion();
}
} }
loadIntentUrl(); loadIntentUrl();
} }
@@ -34,7 +54,25 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
@Override @Override
public void onWindowFocusChanged(boolean hasFocus) { public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus); super.onWindowFocusChanged(hasFocus);
if (hasFocus) enableImmersiveMode(); if (hasFocus) {
enableImmersiveMode();
AndroidDisplayPerformance.preferBatteryRefreshRate(
getWindow(),
getWindowManager().getDefaultDisplay()
);
}
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (
event.getActionMasked() == MotionEvent.ACTION_DOWN
&& bridge != null
&& bridge.getWebView() != null
) {
bridge.getWebView().requestFocus();
}
return super.dispatchTouchEvent(event);
} }
private void loadIntentUrl() { private void loadIntentUrl() {
@@ -61,6 +99,34 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
deleteIfExists(new File(webViewData, "Service Worker")); deleteIfExists(new File(webViewData, "Service Worker"));
} }
private boolean shouldClearWebViewCacheForCurrentVersion() {
return performancePreferences().getLong(CACHE_CLEARED_VERSION_KEY, -1L)
!= currentVersionCode();
}
private void markWebViewCacheClearedForCurrentVersion() {
performancePreferences()
.edit()
.putLong(CACHE_CLEARED_VERSION_KEY, currentVersionCode())
.apply();
}
private SharedPreferences performancePreferences() {
return getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
}
private long currentVersionCode() {
try {
PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return packageInfo.getLongVersionCode();
}
return packageInfo.versionCode;
} catch (PackageManager.NameNotFoundException exception) {
return -1L;
}
}
private void deleteIfExists(File file) { private void deleteIfExists(File file) {
if (!file.exists()) return; if (!file.exists()) return;
if (file.isDirectory()) { if (file.isDirectory()) {
@@ -81,21 +147,120 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (event.getAction() == KeyEvent.ACTION_DOWN) { if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0; boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad()) return true; if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
String script = dispatchNativeControllerToken(token, repeat);
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(
() -> bridge.getWebView().evaluateJavascript(script, null)
);
} }
return true; return true;
} }
private boolean shouldThrottleDpad() { @Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (
bridge == null
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|| !isControllerMotionEvent(event)
) {
return super.dispatchGenericMotionEvent(event);
}
Set<String> currentTokens = new HashSet<>();
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
dispatchNativeControllerMotion(leftStickX, leftStickY);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_X),
"Button14",
"Button15"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_Y),
"Button12",
"Button13"
);
addAxisTokens(
currentTokens,
leftStickX,
"Axis0-",
"Axis0+"
);
addAxisTokens(
currentTokens,
leftStickY,
"Axis1-",
"Axis1+"
);
boolean hadMotionTokens = !activeMotionTokens.isEmpty();
long now = SystemClock.uptimeMillis(); long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true; for (String token : currentTokens) {
lastDpadDispatchAt = now; boolean repeat = activeMotionTokens.contains(token);
long lastDispatchAt = lastMotionDispatchAt.containsKey(token)
? lastMotionDispatchAt.get(token)
: 0L;
if (!repeat || now - lastDispatchAt >= DPAD_THROTTLE_MS) {
dispatchNativeControllerToken(token, repeat);
lastMotionDispatchAt.put(token, now);
}
}
activeMotionTokens.clear();
activeMotionTokens.addAll(currentTokens);
if (currentTokens.isEmpty() && !hadMotionTokens) {
return super.dispatchGenericMotionEvent(event);
}
return true;
}
private boolean isControllerMotionEvent(MotionEvent event) {
int source = event.getSource();
return (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|| (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD
|| (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
}
private void addAxisTokens(
Set<String> tokens,
float value,
String negativeToken,
String positiveToken
) {
if (value <= -AXIS_DEAD_ZONE) tokens.add(negativeToken);
if (value >= AXIS_DEAD_ZONE) tokens.add(positiveToken);
}
private void dispatchNativeControllerToken(String token, boolean repeat) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(
() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
);
}
private void dispatchNativeControllerMotion(float x, float y) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller-motion',"
+ "{detail:{x:" + x + ",y:" + y + "}}));";
bridge.getWebView().post(
() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
);
}
private boolean shouldThrottleDpad(String token) {
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt[buttonIndex] < DPAD_THROTTLE_MS) return true;
lastDpadDispatchAt[buttonIndex] = now;
return false; return false;
} }
@@ -109,10 +274,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
private String controllerToken(int keyCode) { private String controllerToken(int keyCode) {
switch (keyCode) { switch (keyCode) {
case KeyEvent.KEYCODE_BUTTON_A: case KeyEvent.KEYCODE_BUTTON_A:
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_NUMPAD_ENTER:
return "Button0"; return "Button0";
case KeyEvent.KEYCODE_BUTTON_B: case KeyEvent.KEYCODE_BUTTON_B:
case KeyEvent.KEYCODE_BACK: case KeyEvent.KEYCODE_BACK:
case KeyEvent.KEYCODE_ESCAPE:
return "Button1"; return "Button1";
case KeyEvent.KEYCODE_BUTTON_X: case KeyEvent.KEYCODE_BUTTON_X:
return "Button2"; return "Button2";
@@ -259,9 +259,11 @@ public class DualScreenPlugin extends Plugin {
private final class TopDisplayPresentation extends Presentation { private final class TopDisplayPresentation extends Presentation {
private final String initialUrl; private final String initialUrl;
private final Display display;
TopDisplayPresentation(Context context, Display display, String initialUrl) { TopDisplayPresentation(Context context, Display display, String initialUrl) {
super(context, display); super(context, display);
this.display = display;
this.initialUrl = initialUrl; this.initialUrl = initialUrl;
} }
@@ -277,6 +279,7 @@ public class DualScreenPlugin extends Plugin {
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
); );
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), display);
FrameLayout container = new FrameLayout(getContext()); FrameLayout container = new FrameLayout(getContext());
container.setBackgroundColor(Color.BLACK); container.setBackgroundColor(Color.BLACK);
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;
+78 -1
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,
@@ -162,7 +169,8 @@ CREATE TABLE IF NOT EXISTS accounts (
completed_dungeon_parts INTEGER NOT NULL DEFAULT 0, completed_dungeon_parts INTEGER NOT NULL DEFAULT 0,
completed_raid_phases INTEGER NOT NULL DEFAULT 0, completed_raid_phases INTEGER NOT NULL DEFAULT 0,
created_ip TEXT NOT NULL, created_ip TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS account_ip_allowances ( CREATE TABLE IF NOT EXISTS account_ip_allowances (
@@ -247,6 +255,75 @@ CREATE TABLE IF NOT EXISTS character_inventory (
PRIMARY KEY (character_id, item_id) PRIMARY KEY (character_id, item_id)
); );
CREATE TABLE IF NOT EXISTS character_boss_stats (
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE,
kills INTEGER NOT NULL DEFAULT 0 CHECK (kills >= 0),
pet_quantity INTEGER NOT NULL DEFAULT 0 CHECK (pet_quantity >= 0),
PRIMARY KEY (character_id, encounter_id)
);
CREATE TABLE IF NOT EXISTS character_pvp_stats (
character_id INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE,
matches_played INTEGER NOT NULL DEFAULT 0 CHECK (matches_played >= 0),
matches_won INTEGER NOT NULL DEFAULT 0 CHECK (matches_won >= 0)
);
CREATE TABLE IF NOT EXISTS action_characters (
id INTEGER PRIMARY KEY,
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
class_id INTEGER NOT NULL REFERENCES classes(id),
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 1,
experience INTEGER NOT NULL DEFAULT 0,
bulldrome_coins INTEGER NOT NULL DEFAULT 0,
bulldrome_normal_clears INTEGER NOT NULL DEFAULT 0,
bulldrome_hard_clears INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (account_id, class_id)
);
CREATE TABLE IF NOT EXISTS action_gear_items (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL,
name TEXT NOT NULL,
slot TEXT NOT NULL CHECK (slot IN ('weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket')),
item_level INTEGER NOT NULL CHECK (item_level BETWEEN 1 AND 5),
source TEXT NOT NULL DEFAULT 'bulldrome',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS action_character_inventory (
id INTEGER PRIMARY KEY,
action_character_id INTEGER NOT NULL REFERENCES action_characters(id) ON DELETE CASCADE,
item_id INTEGER REFERENCES items(id),
action_gear_item_id INTEGER REFERENCES action_gear_items(id),
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
equipped INTEGER NOT NULL DEFAULT 0 CHECK (equipped IN (0, 1))
);
CREATE TABLE IF NOT EXISTS action_dungeon_runs (
id INTEGER PRIMARY KEY,
action_character_id INTEGER NOT NULL REFERENCES action_characters(id) ON DELETE CASCADE,
dungeon_id INTEGER NOT NULL REFERENCES dungeons(id),
difficulty TEXT NOT NULL CHECK (difficulty IN ('normal', 'hard')),
result TEXT NOT NULL CHECK (result IN ('clear', 'wipe')),
coins_awarded INTEGER NOT NULL DEFAULT 0,
duration_seconds INTEGER NOT NULL DEFAULT 0,
completed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS action_encounter_loot_rolls (
id INTEGER PRIMARY KEY,
action_character_id INTEGER NOT NULL REFERENCES action_characters(id) ON DELETE CASCADE,
dungeon_run_id INTEGER REFERENCES action_dungeon_runs(id) ON DELETE CASCADE,
encounter_id INTEGER NOT NULL REFERENCES encounters(id),
item_id INTEGER NOT NULL REFERENCES items(id),
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
difficulty TEXT NOT NULL CHECK (difficulty IN ('normal', 'hard')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS encounter_loot_rolls ( CREATE TABLE IF NOT EXISTS encounter_loot_rolls (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
+56
View File
@@ -709,6 +709,7 @@ 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 DELETE FROM character_talents
WHERE talent_id IN (SELECT id FROM talents WHERE class_id = 1); WHERE talent_id IN (SELECT id FROM talents WHERE class_id = 1);
@@ -1906,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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+59
View File
@@ -0,0 +1,59 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#2f1f16"/></linearGradient>
<style>
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:20px}.tiny{fill:#d8cab1;font-size:16px}.title{font-size:30px;font-weight:800}.label{font-size:19px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:12}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:10}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:10}.bar-bg{fill:#2a3444;rx:8}.hp{fill:#5ed17a;rx:8}.danger{fill:#d96b55;rx:8}.mana{fill:#63a9ff;rx:8}.button{fill:#d7aa45;rx:10}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}
</style>
</defs>
<rect width="1440" height="900" fill="url(#bg)"/>
<text x="48" y="64" class="title">PC Dungeon PvP Roguelike</text>
<text x="48" y="96" class="muted">Dungeon party = 6 members per side. Upgrade timer auto-picks at 0s.</text>
<rect x="36" y="132" width="402" height="704" class="panel"/>
<text x="64" y="176" class="title">You</text><text x="64" y="204" class="muted">Mira - Priest</text>
<text x="64" y="252" class="label">Party Health</text>
<g transform="translate(64 278)">
<rect width="150" height="82" class="selected"/><text x="16" y="31" class="label">Tank</text><text x="106" y="31" class="tiny">82%</text><rect x="16" y="52" width="118" height="12" class="bar-bg"/><rect x="16" y="52" width="97" height="12" class="hp"/>
<rect x="170" width="150" height="82" class="tile"/><text x="186" y="31" class="label">Mira</text><text x="276" y="31" class="tiny">74%</text><rect x="186" y="52" width="118" height="12" class="bar-bg"/><rect x="186" y="52" width="87" height="12" class="hp"/>
<rect y="104" width="150" height="82" class="tile"/><text x="16" y="135" class="label">DPS</text><text x="106" y="135" class="tiny">55%</text><rect x="16" y="156" width="118" height="12" class="bar-bg"/><rect x="16" y="156" width="65" height="12" class="danger"/>
<rect x="170" y="104" width="150" height="82" class="tile"/><text x="186" y="135" class="label">DPS</text><text x="276" y="135" class="tiny">92%</text><rect x="186" y="156" width="118" height="12" class="bar-bg"/><rect x="186" y="156" width="109" height="12" class="hp"/>
<rect y="208" width="150" height="82" class="tile"/><text x="16" y="239" class="label">DPS</text><text x="106" y="239" class="tiny">66%</text><rect x="16" y="260" width="118" height="12" class="bar-bg"/><rect x="16" y="260" width="78" height="12" class="hp"/>
<rect x="170" y="208" width="150" height="82" class="tile"/><text x="186" y="239" class="label">DPS</text><text x="276" y="239" class="tiny">41%</text><rect x="186" y="260" width="118" height="12" class="bar-bg"/><rect x="186" y="260" width="48" height="12" class="danger"/>
</g>
<text x="64" y="626" class="tiny">Buffs: Wide Radiance x1</text><text x="64" y="654" class="tiny">Debuffs: Mana Squeeze x1</text>
<rect x="472" y="132" width="496" height="704" class="panel"/>
<text x="504" y="176" class="title">Stage 15 Boss</text><text x="504" y="204" class="muted">Bulldrome Guardian</text>
<text x="504" y="260" class="label">Your Clear</text><rect x="504" y="278" width="400" height="24" class="bar-bg"/><rect x="504" y="278" width="176" height="24" class="danger"/>
<text x="504" y="342" class="label">Astra Clear</text><rect x="504" y="360" width="400" height="24" class="bar-bg"/><rect x="504" y="360" width="246" height="24" class="danger"/>
<g transform="translate(520 436)">
<rect width="400" height="260" fill="#161f2c" stroke="#d7aa45" stroke-width="3" rx="14"/>
<text x="32" y="44" class="title">Choose Edge</text><text x="274" y="44" class="title">07.4s</text>
<text x="32" y="88" class="label">Self Buff</text><text x="210" y="88" class="label">Opponent Debuff</text>
<rect x="32" y="112" width="150" height="52" class="selected"/><text x="46" y="145" class="tiny">+1 Target</text>
<rect x="32" y="178" width="150" height="52" class="tile"/><text x="46" y="211" class="tiny">-25% Cost</text>
<rect x="210" y="112" width="150" height="52" class="tile"/><text x="224" y="145" class="tiny">Cost Up</text>
<rect x="210" y="178" width="150" height="52" class="selected"/><text x="224" y="211" class="tiny">Mana Squeeze</text>
</g>
<g transform="translate(520 728)">
<rect width="56" height="52" class="spell"/><text x="22" y="34" font-size="24" font-weight="800">+</text>
<rect x="68" width="56" height="52" class="spell"/><text x="88" y="34" font-size="24" font-weight="800">R</text>
<rect x="136" width="56" height="52" class="spell"/><text x="156" y="34" font-size="24" font-weight="800">S</text>
<rect x="204" width="56" height="52" class="spell"/><text x="224" y="34" font-size="24" font-weight="800">G</text>
<rect x="272" width="56" height="52" class="spell"/><text x="292" y="34" font-size="24" font-weight="800">C</text>
<rect x="340" width="56" height="52" class="spell"/><text x="360" y="34" font-size="24" font-weight="800">B</text>
</g>
<rect x="1002" y="132" width="402" height="704" class="panel"/>
<text x="1030" y="176" class="title">Opponent</text><text x="1030" y="204" class="muted">Astra - Druid</text>
<text x="1030" y="252" class="label">Opponent Party</text>
<g transform="translate(1030 278)">
<rect width="150" height="82" class="tile"/><text x="16" y="31" class="label">Tank</text><text x="106" y="31" class="tiny">59%</text><rect x="16" y="52" width="118" height="12" class="bar-bg"/><rect x="16" y="52" width="70" height="12" class="danger"/>
<rect x="170" width="150" height="82" class="tile"/><text x="186" y="31" class="label">Astra</text><text x="276" y="31" class="tiny">88%</text><rect x="186" y="52" width="118" height="12" class="bar-bg"/><rect x="186" y="52" width="104" height="12" class="hp"/>
<rect y="104" width="150" height="82" class="tile"/><text x="16" y="135" class="label">DPS</text><text x="106" y="135" class="tiny">73%</text><rect x="16" y="156" width="118" height="12" class="bar-bg"/><rect x="16" y="156" width="86" height="12" class="hp"/>
<rect x="170" y="104" width="150" height="82" class="tile"/><text x="186" y="135" class="label">DPS</text><text x="276" y="135" class="tiny">42%</text><rect x="186" y="156" width="118" height="12" class="bar-bg"/><rect x="186" y="156" width="50" height="12" class="danger"/>
<rect y="208" width="150" height="82" class="tile"/><text x="16" y="239" class="label">DPS</text><text x="106" y="239" class="tiny">91%</text><rect x="16" y="260" width="118" height="12" class="bar-bg"/><rect x="16" y="260" width="107" height="12" class="hp"/>
<rect x="170" y="208" width="150" height="82" class="tile"/><text x="186" y="239" class="label">DPS</text><text x="276" y="239" class="tiny">66%</text><rect x="186" y="260" width="118" height="12" class="bar-bg"/><rect x="186" y="260" width="78" height="12" class="hp"/>
</g>
<text x="1030" y="626" class="tiny">Buffs: Dense Shields x1</text><text x="1030" y="654" class="tiny">Debuffs: Cost Up x1</text>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#101827"/><stop offset="1" stop-color="#271a12"/></linearGradient>
<style>
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:15px}.title{font-size:24px;font-weight:800}.label{font-size:16px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:10}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.mana{fill:#63a9ff;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.ready{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:10}
</style>
</defs>
<rect width="960" height="540" fill="url(#bg)"/>
<text x="24" y="38" class="title">Thor Main - Dungeon PvP</text>
<text x="24" y="62" class="muted">Dungeon party shows exactly 6 members. Opponent health stays on bottom screen.</text>
<rect x="24" y="86" width="912" height="350" class="panel"/>
<text x="52" y="124" class="title">Mira Party</text><text x="760" y="124" class="label">Stage 15 Boss</text>
<rect x="760" y="140" width="132" height="16" class="bar-bg"/><rect x="760" y="140" width="58" height="16" class="danger"/>
<g transform="translate(52 164)">
<rect width="132" height="96" class="selected"/><text x="16" y="28" class="label">Tank</text><text x="16" y="52" class="muted">82 / 100</text><rect x="16" y="68" width="100" height="12" class="bar-bg"/><rect x="16" y="68" width="82" height="12" class="hp"/>
<rect x="150" width="132" height="96" class="tile"/><text x="166" y="28" class="label">Mira</text><text x="166" y="52" class="muted">74 / 100</text><rect x="166" y="68" width="100" height="12" class="bar-bg"/><rect x="166" y="68" width="74" height="12" class="hp"/>
<rect x="300" width="132" height="96" class="tile"/><text x="316" y="28" class="label">DPS</text><text x="316" y="52" class="muted">55 / 100</text><rect x="316" y="68" width="100" height="12" class="bar-bg"/><rect x="316" y="68" width="55" height="12" class="danger"/>
<rect y="116" width="132" height="96" class="tile"/><text x="16" y="144" class="label">DPS</text><text x="16" y="168" class="muted">92 / 100</text><rect x="16" y="184" width="100" height="12" class="bar-bg"/><rect x="16" y="184" width="92" height="12" class="hp"/>
<rect x="150" y="116" width="132" height="96" class="tile"/><text x="166" y="144" class="label">DPS</text><text x="166" y="168" class="muted">66 / 100</text><rect x="166" y="184" width="100" height="12" class="bar-bg"/><rect x="166" y="184" width="66" height="12" class="hp"/>
<rect x="300" y="116" width="132" height="96" class="tile"/><text x="316" y="144" class="label">DPS</text><text x="316" y="168" class="muted">41 / 100</text><rect x="316" y="184" width="100" height="12" class="bar-bg"/><rect x="316" y="184" width="41" height="12" class="danger"/>
</g>
<rect x="24" y="452" width="912" height="68" class="panel"/>
<rect x="48" y="468" width="52" height="40" class="ready"/><text x="66" y="496" font-size="22" font-weight="800">+</text>
<rect x="112" y="468" width="52" height="40" class="spell"/><text x="129" y="496" font-size="22" font-weight="800">R</text><text x="143" y="483" font-size="12">3</text>
<rect x="176" y="468" width="52" height="40" class="spell"/><text x="194" y="496" font-size="22" font-weight="800">S</text><text x="207" y="483" font-size="12">8</text>
<rect x="240" y="468" width="52" height="40" class="ready"/><text x="258" y="496" font-size="22" font-weight="800">G</text>
<rect x="304" y="468" width="52" height="40" class="spell"/><text x="322" y="496" font-size="22" font-weight="800">C</text><text x="335" y="483" font-size="12">5</text>
<rect x="368" y="468" width="52" height="40" class="ready"/><text x="386" y="496" font-size="22" font-weight="800">B</text>
<text x="650" y="480" class="label">Mana 73 / 100</text><rect x="650" y="492" width="240" height="14" class="bar-bg"/><rect x="650" y="492" width="175" height="14" class="mana"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+62
View File
@@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900" viewBox="0 0 1440 900">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#2f1f16"/></linearGradient>
<style>
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:20px}.tiny{fill:#d8cab1;font-size:14px}.title{font-size:30px;font-weight:800}.label{font-size:18px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:12}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:8}
</style>
</defs>
<rect width="1440" height="900" fill="url(#bg)"/>
<text x="48" y="64" class="title">PC Raid PvP Roguelike</text>
<text x="48" y="96" class="muted">Raid party = compact roster. Opponent panel mirrors raid health without crowding center.</text>
<rect x="36" y="132" width="430" height="704" class="panel"/>
<text x="64" y="176" class="title">You</text><text x="64" y="204" class="muted">Mira Raid Group</text>
<g transform="translate(64 238)">
<rect width="104" height="56" class="selected"/><text x="10" y="24" class="tiny">Tank 82%</text><rect x="10" y="36" width="84" height="8" class="bar-bg"/><rect x="10" y="36" width="69" height="8" class="hp"/>
<rect x="116" width="104" height="56" class="tile"/><text x="126" y="24" class="tiny">Mira 74%</text><rect x="126" y="36" width="84" height="8" class="bar-bg"/><rect x="126" y="36" width="62" height="8" class="hp"/>
<rect x="232" width="104" height="56" class="tile"/><text x="242" y="24" class="tiny">DPS 55%</text><rect x="242" y="36" width="84" height="8" class="bar-bg"/><rect x="242" y="36" width="46" height="8" class="danger"/>
<rect y="72" width="104" height="56" class="tile"/><text x="10" y="96" class="tiny">DPS 92%</text><rect x="10" y="108" width="84" height="8" class="bar-bg"/><rect x="10" y="108" width="77" height="8" class="hp"/>
<rect x="116" y="72" width="104" height="56" class="tile"/><text x="126" y="96" class="tiny">DPS 66%</text><rect x="126" y="108" width="84" height="8" class="bar-bg"/><rect x="126" y="108" width="55" height="8" class="hp"/>
<rect x="232" y="72" width="104" height="56" class="tile"/><text x="242" y="96" class="tiny">DPS 41%</text><rect x="242" y="108" width="84" height="8" class="bar-bg"/><rect x="242" y="108" width="34" height="8" class="danger"/>
<rect y="144" width="104" height="56" class="tile"/><text x="10" y="168" class="tiny">DPS 88%</text><rect x="10" y="180" width="84" height="8" class="bar-bg"/><rect x="10" y="180" width="74" height="8" class="hp"/>
<rect x="116" y="144" width="104" height="56" class="tile"/><text x="126" y="168" class="tiny">DPS 63%</text><rect x="126" y="180" width="84" height="8" class="bar-bg"/><rect x="126" y="180" width="53" height="8" class="hp"/>
<rect x="232" y="144" width="104" height="56" class="tile"/><text x="242" y="168" class="tiny">DPS 77%</text><rect x="242" y="180" width="84" height="8" class="bar-bg"/><rect x="242" y="180" width="65" height="8" class="hp"/>
<rect y="216" width="104" height="56" class="tile"/><text x="10" y="240" class="tiny">DPS 49%</text><rect x="10" y="252" width="84" height="8" class="bar-bg"/><rect x="10" y="252" width="41" height="8" class="danger"/>
<rect x="116" y="216" width="104" height="56" class="tile"/><text x="126" y="240" class="tiny">DPS 96%</text><rect x="126" y="252" width="84" height="8" class="bar-bg"/><rect x="126" y="252" width="81" height="8" class="hp"/>
<rect x="232" y="216" width="104" height="56" class="tile"/><text x="242" y="240" class="tiny">DPS 70%</text><rect x="242" y="252" width="84" height="8" class="bar-bg"/><rect x="242" y="252" width="59" height="8" class="hp"/>
</g>
<text x="64" y="590" class="tiny">Direct target group: 1 / 2</text><text x="64" y="620" class="tiny">Buffs: Wide Radiance x1</text>
<rect x="502" y="132" width="436" height="704" class="panel"/>
<text x="534" y="176" class="title">Raid Stage 20 Boss</text><text x="534" y="204" class="muted">Ashen Warden</text>
<text x="534" y="260" class="label">Your Clear</text><rect x="534" y="278" width="340" height="24" class="bar-bg"/><rect x="534" y="278" width="176" height="24" class="danger"/>
<text x="534" y="342" class="label">Astra Clear</text><rect x="534" y="360" width="340" height="24" class="bar-bg"/><rect x="534" y="360" width="218" height="24" class="danger"/>
<rect x="534" y="438" width="340" height="220" fill="#161f2c" stroke="#d7aa45" stroke-width="3" rx="14"/>
<text x="558" y="482" class="title">Choose Edge</text><text x="766" y="482" class="title">10.0s</text>
<rect x="558" y="520" width="132" height="48" class="selected"/><text x="572" y="551" class="tiny">Raid Heal +</text>
<rect x="708" y="520" width="132" height="48" class="tile"/><text x="722" y="551" class="tiny">Mana Squeeze</text>
<rect x="558" y="584" width="132" height="48" class="tile"/><text x="572" y="615" class="tiny">Shield Boost</text>
<rect x="708" y="584" width="132" height="48" class="selected"/><text x="722" y="615" class="tiny">Cooldown Up</text>
<g transform="translate(534 724)">
<rect width="48" height="48" class="spell"/><text x="18" y="32" font-size="22" font-weight="800">+</text>
<rect x="60" width="48" height="48" class="spell"/><text x="77" y="32" font-size="22" font-weight="800">R</text>
<rect x="120" width="48" height="48" class="spell"/><text x="137" y="32" font-size="22" font-weight="800">S</text>
<rect x="180" width="48" height="48" class="spell"/><text x="197" y="32" font-size="22" font-weight="800">G</text>
<rect x="240" width="48" height="48" class="spell"/><text x="257" y="32" font-size="22" font-weight="800">C</text>
<rect x="300" width="48" height="48" class="spell"/><text x="317" y="32" font-size="22" font-weight="800">B</text>
</g>
<rect x="974" y="132" width="430" height="704" class="panel"/>
<text x="1002" y="176" class="title">Opponent Raid</text><text x="1002" y="204" class="muted">Astra Group</text>
<g transform="translate(1002 238)">
<rect width="104" height="56" class="tile"/><text x="10" y="24" class="tiny">Tank 61%</text><rect x="10" y="36" width="84" height="8" class="bar-bg"/><rect x="10" y="36" width="51" height="8" class="danger"/>
<rect x="116" width="104" height="56" class="tile"/><text x="126" y="24" class="tiny">Astra 89%</text><rect x="126" y="36" width="84" height="8" class="bar-bg"/><rect x="126" y="36" width="75" height="8" class="hp"/>
<rect x="232" width="104" height="56" class="tile"/><text x="242" y="24" class="tiny">DPS 73%</text><rect x="242" y="36" width="84" height="8" class="bar-bg"/><rect x="242" y="36" width="61" height="8" class="hp"/>
<rect y="72" width="104" height="56" class="tile"/><text x="10" y="96" class="tiny">DPS 42%</text><rect x="10" y="108" width="84" height="8" class="bar-bg"/><rect x="10" y="108" width="35" height="8" class="danger"/>
<rect x="116" y="72" width="104" height="56" class="tile"/><text x="126" y="96" class="tiny">DPS 91%</text><rect x="126" y="108" width="84" height="8" class="bar-bg"/><rect x="126" y="108" width="76" height="8" class="hp"/>
<rect x="232" y="72" width="104" height="56" class="tile"/><text x="242" y="96" class="tiny">DPS 66%</text><rect x="242" y="108" width="84" height="8" class="bar-bg"/><rect x="242" y="108" width="55" height="8" class="hp"/>
<rect y="144" width="104" height="56" class="tile"/><text x="10" y="168" class="tiny">DPS 79%</text><rect x="10" y="180" width="84" height="8" class="bar-bg"/><rect x="10" y="180" width="66" height="8" class="hp"/>
<rect x="116" y="144" width="104" height="56" class="tile"/><text x="126" y="168" class="tiny">DPS 58%</text><rect x="126" y="180" width="84" height="8" class="bar-bg"/><rect x="126" y="180" width="49" height="8" class="danger"/>
<rect x="232" y="144" width="104" height="56" class="tile"/><text x="242" y="168" class="tiny">DPS 84%</text><rect x="242" y="180" width="84" height="8" class="bar-bg"/><rect x="242" y="180" width="71" height="8" class="hp"/>
</g>
<text x="1002" y="590" class="tiny">Opponent buffs/debuffs visible here</text><text x="1002" y="620" class="tiny">Raid bottom screen can scroll second group if needed</text>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

+36
View File
@@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#101827"/><stop offset="1" stop-color="#271a12"/></linearGradient>
<style>
text{font-family:Inter,Arial,sans-serif;fill:#f8f1df}.muted{fill:#c8bca6;font-size:15px}.title{font-size:24px;font-weight:800}.label{font-size:15px;font-weight:700}.panel{fill:#1b2433;stroke:#6f5535;stroke-width:2;rx:10}.tile{fill:#253244;stroke:#7d6743;stroke-width:2;rx:8}.selected{fill:#31483b;stroke:#d7aa45;stroke-width:4;rx:8}.bar-bg{fill:#2a3444;rx:7}.hp{fill:#5ed17a;rx:7}.danger{fill:#d96b55;rx:7}.mana{fill:#63a9ff;rx:7}.spell{fill:#273449;stroke:#8d7349;stroke-width:2;rx:10}.ready{fill:#31483b;stroke:#d7aa45;stroke-width:3;rx:10}
</style>
</defs>
<rect width="960" height="540" fill="url(#bg)"/>
<text x="24" y="38" class="title">Thor Main - Raid PvP</text>
<text x="24" y="62" class="muted">Raid party uses compact tiles plus target group toggle. Bottom screen mirrors opponent raid.</text>
<rect x="24" y="86" width="912" height="350" class="panel"/>
<text x="52" y="122" class="title">Mira Raid</text><text x="214" y="122" class="muted">Target Group 1 / 2</text>
<text x="760" y="122" class="label">Raid Boss</text><rect x="760" y="138" width="132" height="16" class="bar-bg"/><rect x="760" y="138" width="72" height="16" class="danger"/>
<g transform="translate(52 156)">
<rect width="104" height="72" class="selected"/><text x="12" y="28" class="label">Tank</text><text x="62" y="28" class="muted">82%</text><rect x="12" y="46" width="80" height="10" class="bar-bg"/><rect x="12" y="46" width="66" height="10" class="hp"/>
<rect x="118" width="104" height="72" class="tile"/><text x="130" y="28" class="label">Mira</text><text x="180" y="28" class="muted">74%</text><rect x="130" y="46" width="80" height="10" class="bar-bg"/><rect x="130" y="46" width="59" height="10" class="hp"/>
<rect x="236" width="104" height="72" class="tile"/><text x="248" y="28" class="label">DPS</text><text x="298" y="28" class="muted">55%</text><rect x="248" y="46" width="80" height="10" class="bar-bg"/><rect x="248" y="46" width="44" height="10" class="danger"/>
<rect x="354" width="104" height="72" class="tile"/><text x="366" y="28" class="label">DPS</text><text x="416" y="28" class="muted">92%</text><rect x="366" y="46" width="80" height="10" class="bar-bg"/><rect x="366" y="46" width="74" height="10" class="hp"/>
<rect x="472" width="104" height="72" class="tile"/><text x="484" y="28" class="label">DPS</text><text x="534" y="28" class="muted">66%</text><rect x="484" y="46" width="80" height="10" class="bar-bg"/><rect x="484" y="46" width="53" height="10" class="hp"/>
<rect x="590" width="104" height="72" class="tile"/><text x="602" y="28" class="label">DPS</text><text x="652" y="28" class="muted">41%</text><rect x="602" y="46" width="80" height="10" class="bar-bg"/><rect x="602" y="46" width="33" height="10" class="danger"/>
<rect y="92" width="104" height="72" class="tile"/><text x="12" y="120" class="label">DPS</text><text x="62" y="120" class="muted">88%</text><rect x="12" y="138" width="80" height="10" class="bar-bg"/><rect x="12" y="138" width="70" height="10" class="hp"/>
<rect x="118" y="92" width="104" height="72" class="tile"/><text x="130" y="120" class="label">DPS</text><text x="180" y="120" class="muted">63%</text><rect x="130" y="138" width="80" height="10" class="bar-bg"/><rect x="130" y="138" width="50" height="10" class="hp"/>
<rect x="236" y="92" width="104" height="72" class="tile"/><text x="248" y="120" class="label">DPS</text><text x="298" y="120" class="muted">77%</text><rect x="248" y="138" width="80" height="10" class="bar-bg"/><rect x="248" y="138" width="62" height="10" class="hp"/>
<rect x="354" y="92" width="104" height="72" class="tile"/><text x="366" y="120" class="label">DPS</text><text x="416" y="120" class="muted">49%</text><rect x="366" y="138" width="80" height="10" class="bar-bg"/><rect x="366" y="138" width="39" height="10" class="danger"/>
<rect x="472" y="92" width="104" height="72" class="tile"/><text x="484" y="120" class="label">DPS</text><text x="534" y="120" class="muted">96%</text><rect x="484" y="138" width="80" height="10" class="bar-bg"/><rect x="484" y="138" width="77" height="10" class="hp"/>
<rect x="590" y="92" width="104" height="72" class="tile"/><text x="602" y="120" class="label">DPS</text><text x="652" y="120" class="muted">70%</text><rect x="602" y="138" width="80" height="10" class="bar-bg"/><rect x="602" y="138" width="56" height="10" class="hp"/>
</g>
<rect x="24" y="452" width="912" height="68" class="panel"/>
<rect x="48" y="468" width="52" height="40" class="ready"/><text x="66" y="496" font-size="22" font-weight="800">+</text>
<rect x="112" y="468" width="52" height="40" class="spell"/><text x="129" y="496" font-size="22" font-weight="800">R</text><text x="143" y="483" font-size="12">3</text>
<rect x="176" y="468" width="52" height="40" class="spell"/><text x="194" y="496" font-size="22" font-weight="800">S</text><text x="207" y="483" font-size="12">8</text>
<rect x="240" y="468" width="52" height="40" class="ready"/><text x="258" y="496" font-size="22" font-weight="800">G</text>
<rect x="304" y="468" width="52" height="40" class="spell"/><text x="322" y="496" font-size="22" font-weight="800">C</text><text x="335" y="483" font-size="12">5</text>
<rect x="368" y="468" width="52" height="40" class="ready"/><text x="386" y="496" font-size="22" font-weight="800">B</text>
<text x="650" y="480" class="label">Mana 73 / 100</text><rect x="650" y="492" width="240" height="14" class="bar-bg"/><rect x="650" y="492" width="175" height="14" class="mana"/>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

+41
View File
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="540" viewBox="0 0 620 540">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#101827"/>
<stop offset="1" stop-color="#271a12"/>
</linearGradient>
<style>
text { font-family: Inter, Arial, sans-serif; fill: #f8f1df; }
.muted { fill: #c8bca6; font-size: 14px; }
.title { font-size: 22px; font-weight: 800; }
.label { font-size: 15px; font-weight: 700; }
.panel { fill: #1b2433; stroke: #6f5535; stroke-width: 2; rx: 10; }
.tile { fill: #253244; stroke: #7d6743; stroke-width: 2; rx: 8; }
.bar-bg { fill: #2a3444; rx: 7; }
.hp { fill: #5ed17a; rx: 7; }
.danger { fill: #d96b55; rx: 7; }
.mana { fill: #63a9ff; rx: 7; }
</style>
</defs>
<rect width="620" height="540" fill="url(#bg)"/>
<text x="20" y="36" class="title">Thor Bottom Screen - Opponent View</text>
<text x="20" y="60" class="muted">Secondary display shows real opponent health/progress.</text>
<rect x="20" y="82" width="580" height="424" class="panel"/>
<text x="44" y="122" class="title">Astra</text>
<text x="44" y="146" class="muted">Druid - live opponent</text>
<text x="376" y="122" class="label">Opponent Clear</text>
<rect x="376" y="136" width="172" height="16" class="bar-bg"/><rect x="376" y="136" width="106" height="16" class="danger"/>
<text x="44" y="192" class="label">Opponent Party Health</text>
<g transform="translate(44 216)">
<rect width="158" height="72" class="tile"/><text x="14" y="26" class="label">Tank</text><text x="104" y="26" class="muted">59%</text><rect x="14" y="44" width="128" height="12" class="bar-bg"/><rect x="14" y="44" width="76" height="12" class="danger"/>
<rect x="176" width="158" height="72" class="tile"/><text x="190" y="26" class="label">Astra</text><text x="280" y="26" class="muted">88%</text><rect x="190" y="44" width="128" height="12" class="bar-bg"/><rect x="190" y="44" width="113" height="12" class="hp"/>
<rect x="352" width="158" height="72" class="tile"/><text x="366" y="26" class="label">DPS</text><text x="456" y="26" class="muted">73%</text><rect x="366" y="44" width="128" height="12" class="bar-bg"/><rect x="366" y="44" width="93" height="12" class="hp"/>
<rect y="92" width="158" height="72" class="tile"/><text x="14" y="118" class="label">DPS</text><text x="104" y="118" class="muted">42%</text><rect x="14" y="136" width="128" height="12" class="bar-bg"/><rect x="14" y="136" width="54" height="12" class="danger"/>
<rect x="176" y="92" width="158" height="72" class="tile"/><text x="190" y="118" class="label">DPS</text><text x="280" y="118" class="muted">91%</text><rect x="190" y="136" width="128" height="12" class="bar-bg"/><rect x="190" y="136" width="116" height="12" class="hp"/>
<rect x="352" y="92" width="158" height="72" class="tile"/><text x="366" y="118" class="label">DPS</text><text x="456" y="118" class="muted">66%</text><rect x="366" y="136" width="128" height="12" class="bar-bg"/><rect x="366" y="136" width="84" height="12" class="hp"/>
</g>
<text x="44" y="420" class="label">Mana 86 / 100</text>
<rect x="154" y="408" width="214" height="16" class="bar-bg"/><rect x="154" y="408" width="184" height="16" class="mana"/>
<text x="44" y="462" class="muted">Buffs: Dense Shields x1</text>
<text x="318" y="462" class="muted">Debuffs: Cost Up x1</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+80
View File
@@ -10,12 +10,14 @@
"dependencies": { "dependencies": {
"@capacitor/android": "^8.4.0", "@capacitor/android": "^8.4.0",
"@capacitor/core": "^8.4.0", "@capacitor/core": "^8.4.0",
"phaser": "^4.2.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6" "react-dom": "^19.2.6"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/cli": "^8.4.0", "@capacitor/cli": "^8.4.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -801,6 +803,22 @@
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@@ -2013,6 +2031,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2952,6 +2976,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/phaser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.0.tgz",
"integrity": "sha512-9nSQZs4CJ9+V96i2mRC3BBStBcsQWGJJBRpdFYSbpL40/i/QM+wLofaCYMsxNyDk8WJOlHGZUh3uVRKa7lj6yg==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.4"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2972,6 +3005,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": { "node_modules/plist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
+4
View File
@@ -12,6 +12,8 @@
"android:open": "cap open android", "android:open": "cap open android",
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug", "android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk", "android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
"release:gui": "python3 scripts/release_game.py",
"release:cli": "python3 scripts/release_game_cli.py",
"accounts:ip": "node scripts/manage-ip-allowance.mjs", "accounts:ip": "node scripts/manage-ip-allowance.mjs",
"db:backup": "node scripts/backup-db.mjs", "db:backup": "node scripts/backup-db.mjs",
"db:init": "node scripts/init-db.mjs", "db:init": "node scripts/init-db.mjs",
@@ -26,12 +28,14 @@
"dependencies": { "dependencies": {
"@capacitor/android": "^8.4.0", "@capacitor/android": "^8.4.0",
"@capacitor/core": "^8.4.0", "@capacitor/core": "^8.4.0",
"phaser": "^4.2.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6" "react-dom": "^19.2.6"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/cli": "^8.4.0", "@capacitor/cli": "^8.4.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.
+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 {
+14 -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 '+'")
@@ -239,6 +241,12 @@ addColumnIfMissing('encounters', 'image_url', "TEXT NOT NULL DEFAULT '/boss-plac
addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0') addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0') addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('accounts', 'last_saved_at', 'TEXT')
database.prepare(`
UPDATE accounts
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
WHERE last_saved_at IS NULL OR last_saved_at = ''
`).run()
addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)') addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)')
migrateCharacterAccountConstraint() migrateCharacterAccountConstraint()
@@ -249,6 +257,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
+502
View File
@@ -0,0 +1,502 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import queue
import re
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from getpass import getpass
from pathlib import Path
from typing import Callable
try:
import tkinter as tk
from tkinter import messagebox, ttk
except ImportError:
tk = None
messagebox = None
ttk = None
REPO_ROOT = Path(__file__).resolve().parents[1]
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
DEFAULT_JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
DEFAULT_API_BASE_URL = "https://iwanttoheal.phenomrom.com"
DEFAULT_GITEA_URL = "https://git.whoagland.com"
DEFAULT_GITEA_OWNER = "phenom"
DEFAULT_GITEA_REPO = "i-want-to-heal"
DEFAULT_BRANCH = "main"
DEFAULT_TRUENAS_PATH = "/mnt/usbssds/apps/iwanttoheal/app"
DEFAULT_DB_PATH = "/mnt/usbssds/apps/iwanttoheal/data/game.db"
class ReleaseError(RuntimeError):
pass
@dataclass(frozen=True)
class ReleaseConfig:
version: str
token: str
api_base_url: str
java_home: str
gitea_url: str
gitea_owner: str
gitea_repo: str
branch: str
truenas_path: str
truenas_db_path: str
restart_command: str
run_build: bool
run_commit_push: bool
run_release: bool
run_truenas_pull: bool
run_truenas_backup: bool
run_restart: bool
def read_current_version() -> str:
text = GRADLE_FILE.read_text()
match = re.search(r'versionName\s+"([^"]+)"', text)
return match.group(1) if match else ""
def run_command(
args: list[str],
*,
cwd: Path,
env: dict[str, str] | None,
log: Callable[[str], None],
) -> None:
log(f"$ {' '.join(args)}")
proc = subprocess.Popen(
args,
cwd=cwd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
assert proc.stdout is not None
for line in proc.stdout:
log(line.rstrip("\n"))
code = proc.wait()
if code != 0:
raise ReleaseError(f"Command failed ({code}): {' '.join(args)}")
def update_gradle_version(version: str, log: Callable[[str], None]) -> int:
text = GRADLE_FILE.read_text()
code_match = re.search(r"versionCode\s+(\d+)", text)
if not code_match:
raise ReleaseError(f"Could not find versionCode in {GRADLE_FILE}")
next_code = int(code_match.group(1)) + 1
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
GRADLE_FILE.write_text(text)
log(f"Android version set: versionName {version}, versionCode {next_code}")
return next_code
def build_apk(config: ReleaseConfig, log: Callable[[str], None]) -> Path:
update_gradle_version(config.version, log)
env = os.environ.copy()
env["JAVA_HOME"] = config.java_home
env["PATH"] = f"{config.java_home}/bin:{env.get('PATH', '')}"
env["VITE_API_BASE_URL"] = config.api_base_url
run_command(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env, log=log)
run_command(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env, log=log)
apk_source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
if not apk_source.exists():
raise ReleaseError(f"APK not found: {apk_source}")
apk_target = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
apk_target.write_bytes(apk_source.read_bytes())
log(f"APK copied: {apk_target.name} ({apk_target.stat().st_size:,} bytes)")
return apk_target
def commit_and_push(config: ReleaseConfig, log: Callable[[str], None]) -> None:
message = f"Android build v{config.version}"
run_command(["git", "add", "."], cwd=REPO_ROOT, env=None, log=log)
run_command(["git", "commit", "-m", message], cwd=REPO_ROOT, env=None, log=log)
run_command(["git", "push", "origin", config.branch], cwd=REPO_ROOT, env=None, log=log)
def gitea_request(
config: ReleaseConfig,
method: str,
path: str,
*,
body: bytes | None = None,
content_type: str | None = None,
) -> tuple[int, bytes]:
url = config.gitea_url.rstrip("/") + path
headers = {"Authorization": f"token {config.token}"}
if content_type:
headers["Content-Type"] = content_type
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as exc:
data = exc.read()
return exc.code, data
def parse_json_response(status: int, data: bytes, action: str) -> dict:
try:
parsed = json.loads(data.decode("utf-8") or "{}")
except json.JSONDecodeError as exc:
raise ReleaseError(f"{action} failed ({status}): {data.decode('utf-8', 'replace')}") from exc
if status >= 400:
raise ReleaseError(f"{action} failed ({status}): {parsed}")
if not isinstance(parsed, dict):
raise ReleaseError(f"{action} returned unexpected JSON: {parsed}")
return parsed
def find_or_create_release(config: ReleaseConfig, log: Callable[[str], None]) -> int:
repo_path = f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
tag = f"v{config.version}"
tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
status, data = gitea_request(config, "GET", tag_path)
if status == 200:
release = parse_json_response(status, data, "Find release")
release_id = release.get("id")
if not release_id:
raise ReleaseError(f"Existing release missing id: {release}")
log(f"Gitea release exists: {tag} (id {release_id})")
return int(release_id)
if status != 404:
parse_json_response(status, data, "Find release")
payload = {
"tag_name": tag,
"target_commitish": config.branch,
"name": tag,
"body": f"I Want to Heal Android build v{config.version}",
"draft": False,
"prerelease": False,
}
status, data = gitea_request(
config,
"POST",
repo_path + "/releases",
body=json.dumps(payload).encode("utf-8"),
content_type="application/json",
)
release = parse_json_response(status, data, "Create release")
release_id = release.get("id")
if not release_id:
raise ReleaseError(f"Created release missing id: {release}")
log(f"Gitea release created: {tag} (id {release_id})")
return int(release_id)
def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> None:
apk_path = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
if not apk_path.exists():
raise ReleaseError(f"APK not found for release upload: {apk_path}")
release_id = find_or_create_release(config, log)
boundary = f"----iwanttoheal{int(time.time() * 1000)}"
header = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
body = header + apk_path.read_bytes() + footer
asset_name = urllib.parse.quote(apk_path.name)
path = (
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
f"/releases/{release_id}/assets?name={asset_name}"
)
status, data = gitea_request(
config,
"POST",
path,
body=body,
content_type=f"multipart/form-data; boundary={boundary}",
)
if status == 409:
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
parse_json_response(status, data, "Upload release asset")
log(f"Gitea asset uploaded: {apk_path.name}")
def update_truenas(config: ReleaseConfig, log: Callable[[str], None]) -> None:
app_path = Path(config.truenas_path)
if not app_path.exists():
raise ReleaseError(f"TrueNAS path not found: {app_path}")
run_command(["git", "pull"], cwd=app_path, env=None, log=log)
if config.run_truenas_backup:
db_path = Path(config.truenas_db_path)
if not db_path.exists():
raise ReleaseError(f"TrueNAS database not found: {db_path}")
stamp = time.strftime("%Y%m%d-%H%M%S")
backup_path = db_path.with_name(f"game-before-update-{stamp}.db")
backup_path.write_bytes(db_path.read_bytes())
log(f"TrueNAS database backup copied: {backup_path}")
if config.run_restart:
if not config.restart_command.strip():
raise ReleaseError("Restart command is empty")
run_command(["sh", "-lc", config.restart_command], cwd=app_path, env=None, log=log)
def validate_config(config: ReleaseConfig) -> None:
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", config.version):
raise ReleaseError("Version must look like 1.1.2")
if config.run_release and not config.token:
raise ReleaseError("Gitea token required for release step")
if config.run_restart and not config.run_truenas_pull:
raise ReleaseError("Restart needs TrueNAS update step enabled")
def prompt_text(label: str, default: str = "", *, secret: bool = False) -> str:
prompt = f"{label}"
if default:
prompt += f" [{default}]"
prompt += ": "
try:
value = getpass(prompt) if secret else input(prompt)
except EOFError:
value = ""
return value.strip() or default
def prompt_bool(label: str, default: bool) -> bool:
suffix = "Y/n" if default else "y/N"
try:
value = input(f"{label} [{suffix}]: ").strip().lower()
except EOFError:
value = ""
if not value:
return default
return value in {"y", "yes", "true", "1"}
def run_release_steps(config: ReleaseConfig, log: Callable[[str], None]) -> None:
if config.run_build:
build_apk(config, log)
if config.run_commit_push:
commit_and_push(config, log)
if config.run_release:
upload_release_asset(config, log)
if config.run_truenas_pull:
update_truenas(config, log)
def cli_main() -> int:
print("Tkinter not available. CLI mode.")
current_version = read_current_version()
config = ReleaseConfig(
version=prompt_text("Version", current_version),
token=prompt_text("Gitea token", secret=True),
api_base_url=prompt_text("API base URL", DEFAULT_API_BASE_URL),
java_home=prompt_text("JAVA_HOME", DEFAULT_JAVA_HOME),
gitea_url=prompt_text("Gitea URL", DEFAULT_GITEA_URL),
gitea_owner=prompt_text("Gitea owner", DEFAULT_GITEA_OWNER),
gitea_repo=prompt_text("Gitea repo", DEFAULT_GITEA_REPO),
branch=prompt_text("Git branch", DEFAULT_BRANCH),
truenas_path=prompt_text("TrueNAS app path", DEFAULT_TRUENAS_PATH),
truenas_db_path=prompt_text("TrueNAS DB path", DEFAULT_DB_PATH),
restart_command=prompt_text("Restart command"),
run_build=prompt_bool("Build APK", True),
run_commit_push=prompt_bool("Commit + push", True),
run_release=prompt_bool("Create Gitea release", True),
run_truenas_pull=prompt_bool("TrueNAS pull", True),
run_truenas_backup=prompt_bool("Backup DB", True),
run_restart=prompt_bool("Restart", False),
)
validate_config(config)
run_release_steps(config, print)
print("Release steps complete")
return 0
if tk is not None and ttk is not None and messagebox is not None:
class ReleaseApp(ttk.Frame):
def __init__(self, root: tk.Tk) -> None:
super().__init__(root, padding=12)
self.root = root
self.log_queue: queue.Queue[tuple[str, str]] = queue.Queue()
self.worker: threading.Thread | None = None
self.vars: dict[str, tk.Variable] = {}
self.grid(sticky="nsew")
root.title("I Want to Heal Release Tool")
root.geometry("920x720")
root.columnconfigure(0, weight=1)
root.rowconfigure(0, weight=1)
self.columnconfigure(1, weight=1)
self.rowconfigure(13, weight=1)
self._build_form()
self.after(100, self._drain_log_queue)
def _var(self, name: str, value: object = "") -> tk.Variable:
if isinstance(value, bool):
var: tk.Variable = tk.BooleanVar(value=value)
else:
var = tk.StringVar(value=str(value))
self.vars[name] = var
return var
def _build_form(self) -> None:
current_version = read_current_version()
fields = [
("Version", "version", current_version),
("Gitea token", "token", ""),
("API base URL", "api_base_url", DEFAULT_API_BASE_URL),
("JAVA_HOME", "java_home", DEFAULT_JAVA_HOME),
("Gitea URL", "gitea_url", DEFAULT_GITEA_URL),
("Gitea owner", "gitea_owner", DEFAULT_GITEA_OWNER),
("Gitea repo", "gitea_repo", DEFAULT_GITEA_REPO),
("Git branch", "branch", DEFAULT_BRANCH),
("TrueNAS app path", "truenas_path", DEFAULT_TRUENAS_PATH),
("TrueNAS DB path", "truenas_db_path", DEFAULT_DB_PATH),
("Restart command", "restart_command", ""),
]
for row, (label, name, value) in enumerate(fields):
ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=3)
entry = ttk.Entry(self, textvariable=self._var(name, value), show="*" if name == "token" else "")
entry.grid(row=row, column=1, sticky="ew", pady=3)
checks = ttk.Frame(self)
checks.grid(row=11, column=0, columnspan=2, sticky="ew", pady=(10, 4))
check_data = [
("Build APK", "run_build", True),
("Commit + push", "run_commit_push", True),
("Create Gitea release", "run_release", True),
("TrueNAS pull", "run_truenas_pull", True),
("Backup DB", "run_truenas_backup", True),
("Restart", "run_restart", False),
]
for index, (text, name, value) in enumerate(check_data):
ttk.Checkbutton(checks, text=text, variable=self._var(name, value)).grid(
row=0, column=index, padx=(0, 14), sticky="w"
)
buttons = ttk.Frame(self)
buttons.grid(row=12, column=0, columnspan=2, sticky="ew", pady=(6, 8))
self.run_button = ttk.Button(buttons, text="Run Selected Steps", command=self.run_release)
self.run_button.pack(side="left")
ttk.Button(buttons, text="Start Admin Server", command=self.start_admin).pack(side="left", padx=8)
self.log_text = tk.Text(self, height=18, wrap="word")
self.log_text.grid(row=13, column=0, columnspan=2, sticky="nsew")
scroll = ttk.Scrollbar(self, command=self.log_text.yview)
scroll.grid(row=13, column=2, sticky="ns")
self.log_text.configure(yscrollcommand=scroll.set)
def config_from_form(self) -> ReleaseConfig:
get = lambda name: str(self.vars[name].get()).strip()
get_bool = lambda name: bool(self.vars[name].get())
return ReleaseConfig(
version=get("version"),
token=get("token"),
api_base_url=get("api_base_url"),
java_home=get("java_home"),
gitea_url=get("gitea_url"),
gitea_owner=get("gitea_owner"),
gitea_repo=get("gitea_repo"),
branch=get("branch"),
truenas_path=get("truenas_path"),
truenas_db_path=get("truenas_db_path"),
restart_command=get("restart_command"),
run_build=get_bool("run_build"),
run_commit_push=get_bool("run_commit_push"),
run_release=get_bool("run_release"),
run_truenas_pull=get_bool("run_truenas_pull"),
run_truenas_backup=get_bool("run_truenas_backup"),
run_restart=get_bool("run_restart"),
)
def log(self, message: str) -> None:
self.log_queue.put(("log", message))
def _drain_log_queue(self) -> None:
try:
while True:
kind, message = self.log_queue.get_nowait()
if kind == "done":
self.run_button.configure(state="normal")
if message:
messagebox.showinfo("Release tool", message)
elif kind == "error":
self.run_button.configure(state="normal")
messagebox.showerror("Release failed", message)
else:
self.log_text.insert("end", message + "\n")
self.log_text.see("end")
except queue.Empty:
pass
self.after(100, self._drain_log_queue)
def run_release(self) -> None:
if self.worker and self.worker.is_alive():
return
try:
config = self.config_from_form()
validate_config(config)
except ReleaseError as exc:
messagebox.showerror("Invalid settings", str(exc))
return
self.run_button.configure(state="disabled")
self.log_text.delete("1.0", "end")
self.worker = threading.Thread(target=self._run_release_worker, args=(config,), daemon=True)
self.worker.start()
def _run_release_worker(self, config: ReleaseConfig) -> None:
try:
run_release_steps(config, self.log)
self.log_queue.put(("done", "Release steps complete"))
except Exception as exc:
self.log_queue.put(("error", str(exc)))
def start_admin(self) -> None:
if self.worker and self.worker.is_alive():
messagebox.showerror("Busy", "Release steps are still running")
return
self.log_text.delete("1.0", "end")
self.run_button.configure(state="disabled")
self.worker = threading.Thread(target=self._start_admin_worker, daemon=True)
self.worker.start()
def _start_admin_worker(self) -> None:
try:
self.log("Admin URL: http://127.0.0.1:4174")
run_command(["npm", "run", "admin:start"], cwd=REPO_ROOT, env=None, log=self.log)
self.log_queue.put(("done", "Admin server stopped"))
except Exception as exc:
self.log_queue.put(("error", str(exc)))
def main() -> int:
if not GRADLE_FILE.exists():
print(f"Missing Gradle file: {GRADLE_FILE}", file=sys.stderr)
return 1
if tk is None:
return cli_main()
root = tk.Tk()
ReleaseApp(root)
root.mainloop()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
API_BASE_URL = "https://iwanttoheal.phenomrom.com"
GITEA_URL = "https://git.whoagland.com"
GITEA_OWNER = "phenom"
GITEA_REPO = "i-want-to-heal"
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
BRANCH = "main"
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app")
PLAYER_DATA_TABLES = [
"encounter_loot_roll_items",
"encounter_loot_rolls",
"dungeon_runs",
"character_pvp_stats",
"character_boss_stats",
"character_inventory",
"character_talents",
"character_ability_slots",
"action_encounter_loot_rolls",
"action_dungeon_runs",
"action_character_inventory",
"action_gear_items",
"action_characters",
"sessions",
"characters",
"accounts",
]
def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
print(f"$ {' '.join(args)}", flush=True)
subprocess.run(args, cwd=cwd, env=env, check=True)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build and publish an Android release.")
parser.add_argument(
"--reset-player-data",
action="store_true",
help="After release, back up the database and delete all accounts, characters, sessions, runs, loot, inventory, and PvP stats.",
)
parser.add_argument(
"--reset-db",
type=Path,
help="SQLite game.db path to reset. Defaults to mounted TrueNAS data/game.db when present, otherwise local data/game.db.",
)
parser.add_argument(
"--yes-reset",
action="store_true",
help="Skip the RESET confirmation when used with --reset-player-data.",
)
args = parser.parse_args(argv)
if args.yes_reset and not args.reset_player_data:
parser.error("--yes-reset requires --reset-player-data")
return args
def prompt_version() -> str:
current = GRADLE_FILE.read_text()
match = re.search(r'versionName\s+"([^"]+)"', current)
current_version = match.group(1) if match else ""
suffix = f" [{current_version}]" if current_version else ""
version = input(f"Version{suffix}: ").strip() or current_version
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
raise SystemExit("Version must look like 1.1.2")
return version
def update_gradle_version(version: str) -> None:
text = GRADLE_FILE.read_text()
code_match = re.search(r"versionCode\s+(\d+)", text)
if not code_match:
raise SystemExit(f"Could not find versionCode in {GRADLE_FILE}")
next_code = int(code_match.group(1)) + 1
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
GRADLE_FILE.write_text(text)
print(f"Android version: {version}, versionCode: {next_code}")
def build_apk(version: str) -> Path:
update_gradle_version(version)
env = os.environ.copy()
env["JAVA_HOME"] = JAVA_HOME
env["PATH"] = f"{JAVA_HOME}/bin:{env.get('PATH', '')}"
env["VITE_API_BASE_URL"] = API_BASE_URL
run(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env)
run(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env)
source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
shutil.copy2(source, apk)
print(f"APK: {apk}")
return apk
def commit_and_push(version: str) -> None:
run(["git", "add", "."], cwd=REPO_ROOT)
run(["git", "commit", "-m", f"Android build v{version}"], cwd=REPO_ROOT)
run(["git", "push", "origin", BRANCH], cwd=REPO_ROOT)
def request_json(method: str, path: str, token: str, data: dict | None = None) -> dict:
body = json.dumps(data).encode("utf-8") if data is not None else None
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
def upload_asset(path: str, token: str, apk: Path) -> dict:
boundary = "----iwanttoheal-release-boundary"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n'
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
body += apk.read_bytes()
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
headers = {
"Authorization": f"token {token}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
}
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc
def create_gitea_release(version: str, apk: Path) -> None:
token = GITEA_TOKEN.strip()
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
release = request_json(
"POST",
repo_path + "/releases",
token,
{
"tag_name": f"v{version}",
"target_commitish": BRANCH,
"name": f"v{version}",
"body": f"I Want to Heal Android build v{version}",
"draft": False,
"prerelease": False,
},
)
release_id = release.get("id")
if not release_id:
raise SystemExit(f"Gitea release missing id: {release}")
asset_name = urllib.parse.quote(apk.name)
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
print(f"Gitea release uploaded: v{version}")
def update_truenas() -> None:
if TRUENAS_PATH.exists():
run(["git", "pull"], cwd=TRUENAS_PATH)
print("Restart app in TrueNAS UI.")
return
print("TrueNAS path not mounted here. Run on TrueNAS:")
print(f"cd {TRUENAS_PATH}")
print("git pull")
print("Then restart app in TrueNAS UI.")
def default_reset_database_path() -> Path:
truenas_database = TRUENAS_PATH / "data" / "game.db"
if truenas_database.exists():
return truenas_database
return REPO_ROOT / "data" / "game.db"
def table_exists(database: sqlite3.Connection, table: str) -> bool:
return database.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
).fetchone() is not None
def count_rows(database: sqlite3.Connection, table: str) -> int:
if not table_exists(database, table):
return 0
return int(database.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0])
def backup_database(database_path: Path) -> Path:
backup_dir = database_path.parent / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = backup_dir / f"{database_path.stem}-pre-player-reset-{timestamp}{database_path.suffix}"
source = sqlite3.connect(database_path)
try:
destination = sqlite3.connect(backup_path)
try:
source.backup(destination)
finally:
destination.close()
finally:
source.close()
return backup_path
def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
database_path = database_path.expanduser().resolve()
if not database_path.exists():
raise SystemExit(f"Database not found: {database_path}")
if not assume_yes:
print(f"Reset target: {database_path}")
confirmation = input("Type RESET to delete all accounts/characters/player data: ").strip()
if confirmation != "RESET":
raise SystemExit("Player data reset cancelled.")
backup_path = backup_database(database_path)
database = sqlite3.connect(database_path)
try:
before = {
"accounts": count_rows(database, "accounts"),
"characters": count_rows(database, "characters"),
"action_characters": count_rows(database, "action_characters"),
"dungeon_runs": count_rows(database, "dungeon_runs"),
"encounter_loot_rolls": count_rows(database, "encounter_loot_rolls"),
}
database.execute("PRAGMA foreign_keys = ON")
database.execute("BEGIN")
try:
for table in PLAYER_DATA_TABLES:
if table_exists(database, table):
database.execute(f'DELETE FROM "{table}"')
database.execute("COMMIT")
except Exception:
database.execute("ROLLBACK")
raise
after_accounts = count_rows(database, "accounts")
after_characters = count_rows(database, "characters")
if after_accounts or after_characters:
raise SystemExit(
f"Player data reset incomplete: {after_accounts} accounts, {after_characters} characters remain."
)
print(f"Database backup: {backup_path}")
print(
"Player data reset: "
f"{before['accounts']} accounts, "
f"{before['characters']} characters, "
f"{before['action_characters']} action characters, "
f"{before['dungeon_runs']} dungeon runs, "
f"{before['encounter_loot_rolls']} loot rolls removed."
)
finally:
database.close()
def main(argv: list[str] | None = None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
os.chdir(REPO_ROOT)
version = prompt_version()
apk = build_apk(version)
commit_and_push(version)
create_gitea_release(version, apk)
update_truenas()
if args.reset_player_data:
reset_player_data(args.reset_db or default_reset_database_path(), assume_yes=args.yes_reset)
print("Done.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+384 -8
View File
@@ -1,4 +1,4 @@
import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs' import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { extname, resolve, sep } from 'node:path' import { extname, resolve, sep } from 'node:path'
@@ -9,8 +9,11 @@ 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 iwt2BalanceOverridesPath = fileURLToPath(new URL('../src/modes/iwt2/content/balanceOverrides.ts', 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 +29,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 +168,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 +230,108 @@ 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 readIwt2BalanceOverrides() {
if (!existsSync(iwt2BalanceOverridesPath)) return { bosses: {} }
const source = readFileSync(iwt2BalanceOverridesPath, 'utf8')
const exportIndex = source.indexOf('IWT2_BALANCE_OVERRIDES')
const objectStart = source.indexOf('{', exportIndex)
const objectEnd = source.lastIndexOf('}')
if (exportIndex < 0 || objectStart < 0 || objectEnd <= objectStart) return { bosses: {} }
try {
const parsed = JSON.parse(source.slice(objectStart, objectEnd + 1))
return sanitizeIwt2BalanceOverrides(parsed)
} catch {
return { bosses: {} }
}
}
function sanitizeIwt2BalanceOverrides(value) {
const overrides = { bosses: {} }
const bosses = value && typeof value === 'object' && value.bosses && typeof value.bosses === 'object'
? value.bosses
: {}
for (const bossId of ['bulldrome', 'yian-kut-ku']) {
const boss = bosses[bossId]
if (!boss || typeof boss !== 'object') continue
const nextBoss = {}
if (Number.isFinite(Number(boss.maxHealth))) nextBoss.maxHealth = Math.max(1, Math.round(Number(boss.maxHealth)))
if (bossId === 'yian-kut-ku' && Number.isFinite(Number(boss.birdHealth))) {
nextBoss.birdHealth = Math.max(1, Math.round(Number(boss.birdHealth)))
}
if (Object.keys(nextBoss).length > 0) overrides.bosses[bossId] = nextBoss
}
return overrides
}
function writeIwt2BalanceOverrides(overrides) {
const source = [
"import type { Iwt2BalanceOverrides } from './bosses'",
'',
'// Generated by local admin panel. Commit this file with intended IWT2 balance changes.',
`export const IWT2_BALANCE_OVERRIDES: Iwt2BalanceOverrides = ${JSON.stringify(sanitizeIwt2BalanceOverrides(overrides), null, 2)}`,
'',
].join('\n')
writeFileSync(iwt2BalanceOverridesPath, source, { mode: 0o644 })
}
function updateIwt2EntityHp(entityId, payload) {
const maxHealth = Number(payload.maxHealth)
if (!Number.isFinite(maxHealth) || maxHealth <= 0) throw new Error('HP must be greater than zero.')
const roundedHealth = Math.round(maxHealth)
const overrides = readIwt2BalanceOverrides()
const bosses = { ...(overrides.bosses ?? {}) }
if (entityId === 'bulldrome' || entityId === 'yian-kut-ku') {
bosses[entityId] = {
...(bosses[entityId] ?? {}),
maxHealth: roundedHealth,
}
} else if (entityId === 'yian-kut-ku:yian-bird' || entityId === 'yian-bird') {
bosses['yian-kut-ku'] = {
...(bosses['yian-kut-ku'] ?? {}),
birdHealth: roundedHealth,
}
} else {
throw new Error('IWT2 entity not found.')
}
const nextOverrides = sanitizeIwt2BalanceOverrides({ bosses })
writeIwt2BalanceOverrides(nextOverrides)
return nextOverrides
}
function listIwt2BossAssets() {
if (!existsSync(bossImageDirectory)) return []
return readdirSync(bossImageDirectory, { withFileTypes: true })
.filter((entry) => entry.isFile() && bossImageContentTypes[extname(entry.name).toLowerCase()])
.map((entry) => {
const imagePath = resolve(bossImageDirectory, entry.name)
return {
filename: entry.name,
size: statSync(imagePath).size,
url: `/api/boss-images/${entry.name}`,
}
})
.sort((a, b) => a.filename.localeCompare(b.filename))
}
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 +387,27 @@ const server = createServer(async (request, response) => {
return return
} }
if (request.url.startsWith('/api/dungeon-images/') && request.method === 'GET') {
sendDungeonImage(request, response)
return
}
if (request.url === '/api/admin/iwt2/data' && request.method === 'GET') {
sendJson(response, 200, {
balanceOverrides: readIwt2BalanceOverrides(),
bossAssets: listIwt2BossAssets(),
})
return
}
const iwt2HpMatch = request.url.match(/^\/api\/admin\/iwt2\/entities\/([^/]+)\/hp$/)
if (iwt2HpMatch && request.method === 'PUT') {
const payload = await readJson(request)
const balanceOverrides = updateIwt2EntityHp(decodeURIComponent(iwt2HpMatch[1]), payload)
sendJson(response, 200, { ok: true, balanceOverrides })
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 +428,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 +464,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 +521,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 +530,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 +559,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 +612,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 +621,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 +629,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 +664,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
} }
+80
View File
@@ -0,0 +1,80 @@
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),
hunterStats: {
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
pvpMatchesWon: 0,
},
}
}
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,
}
}
+565 -53
View File
@@ -9,6 +9,12 @@ 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'
import {
catchUpExperienceReward as catchUpExperienceRewardForTarget,
coinDropQuantity,
roguelikeCoinItemLevel,
} from '../src/shared/rewardRules.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))
@@ -26,6 +32,39 @@ 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()
const pvpQueue = new Map()
const pvpMatches = new Map()
const pvpQueueTtlMs = 15 * 1000
const pvpMatchTtlMs = 60 * 60 * 1000
function addColumnIfMissing(database, table, column, definition) {
const columns = database.prepare(`PRAGMA table_info(${table})`).all()
if (!columns.some((candidate) => candidate.name === column)) {
database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
}
}
function ensureRuntimeMigrations(database) {
addColumnIfMissing(database, 'accounts', 'last_saved_at', 'TEXT')
database.prepare(`
UPDATE accounts
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
WHERE last_saved_at IS NULL OR last_saved_at = ''
`).run()
}
function sqliteDateFromMs(value) {
const timestamp = Number(value)
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return new Date().toISOString()
}
return new Date(timestamp).toISOString()
}
function epochMsFromSqliteDate(value) {
const timestamp = Date.parse(String(value ?? ''))
return Number.isFinite(timestamp) ? timestamp : 0
}
function sendJson(response, status, body, headers = {}) { function sendJson(response, status, body, headers = {}) {
response.statusCode = status response.statusCode = status
@@ -34,6 +73,19 @@ function sendJson(response, status, body, headers = {}) {
response.end(JSON.stringify(body)) response.end(JSON.stringify(body))
} }
function touchAccountSave(database, accountId) {
database.prepare(`
UPDATE accounts
SET last_saved_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(accountId)
}
function sendMutatingResult(response, database, accountId, body) {
touchAccountSave(database, accountId)
sendJson(response, 200, body)
}
function configuredCorsOrigins() { function configuredCorsOrigins() {
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '') return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
.split(',') .split(',')
@@ -246,10 +298,7 @@ function catchUpExperienceReward(database, accountId, characterId, baseReward, c
FROM level_progression FROM level_progression
WHERE level = ? WHERE level = ?
`).get(targetLevel)?.experienceRequired ?? currentExperience `).get(targetLevel)?.experienceRequired ?? currentExperience
const gap = Math.max(0, targetExperience - currentExperience) return catchUpExperienceRewardForTarget(baseReward, currentExperience, targetExperience)
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) {
@@ -348,10 +397,13 @@ function currentSession(database, request) {
accounts.id AS accountId, accounts.id AS accountId,
accounts.username, accounts.username,
characters.id AS characterId, characters.id AS characterId,
characters.class_id AS classId characters.class_id AS classId,
characters.name AS characterName,
classes.name AS className
FROM sessions FROM sessions
JOIN accounts ON accounts.id = sessions.account_id JOIN accounts ON accounts.id = sessions.account_id
JOIN characters ON characters.id = sessions.active_character_id JOIN characters ON characters.id = sessions.active_character_id
JOIN classes ON classes.id = characters.class_id
WHERE sessions.token_hash = ? WHERE sessions.token_hash = ?
AND sessions.expires_at > CURRENT_TIMESTAMP AND sessions.expires_at > CURRENT_TIMESTAMP
`).get(tokenHash(token)) ?? null `).get(tokenHash(token)) ?? null
@@ -590,6 +642,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
@@ -748,6 +801,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,
@@ -799,6 +857,21 @@ export function getProfile(database, characterId, accountId) {
AND completed_parts >= 1 AND completed_parts >= 1
GROUP BY dungeon_id GROUP BY dungeon_id
`).all(characterId).map((row) => [row.dungeonId, row.count])) `).all(characterId).map((row) => [row.dungeonId, row.count]))
const bossStatRows = database.prepare(`
SELECT
encounter_id AS encounterId,
kills,
pet_quantity AS petQuantity
FROM character_boss_stats
WHERE character_id = ?
`).all(characterId)
const pvpStats = database.prepare(`
SELECT
matches_played AS matchesPlayed,
matches_won AS matchesWon
FROM character_pvp_stats
WHERE character_id = ?
`).get(characterId)
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()
@@ -843,6 +916,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)
@@ -851,6 +925,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,
@@ -873,7 +949,8 @@ 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) => ({
@@ -910,6 +987,12 @@ export function getProfile(database, characterId, accountId) {
.map((run) => ({ ...run, rank: Number(run.rank) })), .map((run) => ({ ...run, rank: Number(run.rank) })),
}, },
})), })),
hunterStats: {
bossKills: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.kills])),
bossPets: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.petQuantity])),
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
},
} }
} }
@@ -986,7 +1069,8 @@ function buildSyncSave(database, accountId, activeCharacterId) {
const account = database.prepare(` const account = database.prepare(`
SELECT SELECT
completed_dungeon_parts AS completedDungeonParts, completed_dungeon_parts AS completedDungeonParts,
completed_raid_phases AS completedRaidPhases completed_raid_phases AS completedRaidPhases,
last_saved_at AS lastSavedAt
FROM accounts FROM accounts
WHERE id = ? WHERE id = ?
`).get(accountId) `).get(accountId)
@@ -1005,8 +1089,19 @@ function buildSyncSave(database, accountId, activeCharacterId) {
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
?? characters[0]?.name ?? characters[0]?.name
?? 'Mira' ?? 'Mira'
const bossStats = database.prepare(`
SELECT encounter_id AS encounterId, kills, pet_quantity AS petQuantity
FROM character_boss_stats
WHERE character_id = ?
`).all(activeCharacterId)
const pvpStats = database.prepare(`
SELECT matches_played AS matchesPlayed, matches_won AS matchesWon
FROM character_pvp_stats
WHERE character_id = ?
`).get(activeCharacterId)
return { return {
version: 3, version: 4,
updatedAt: epochMsFromSqliteDate(account?.lastSavedAt),
characterName, characterName,
activeClassId, activeClassId,
completedDungeonParts: account?.completedDungeonParts ?? 0, completedDungeonParts: account?.completedDungeonParts ?? 0,
@@ -1018,6 +1113,10 @@ function buildSyncSave(database, accountId, activeCharacterId) {
]), ]),
), ),
lootRolls: {}, lootRolls: {},
bossKills: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.kills])),
bossPets: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.petQuantity])),
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
} }
} }
@@ -1032,7 +1131,7 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
if ( if (
!save !save
|| typeof save !== 'object' || typeof save !== 'object'
|| Number(save.version) !== 3 || (Number(save.version) !== 4 && Number(save.version) !== 3)
|| typeof save.characterName !== 'string' || typeof save.characterName !== 'string'
|| !save.characters || !save.characters
|| typeof save.characters !== 'object' || typeof save.characters !== 'object'
@@ -1109,11 +1208,12 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
database.prepare(` database.prepare(`
UPDATE accounts UPDATE accounts
SET completed_dungeon_parts = ?, completed_raid_phases = ? SET completed_dungeon_parts = ?, completed_raid_phases = ?, last_saved_at = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
clampInteger(save.completedDungeonParts, 0, 0, 3), clampInteger(save.completedDungeonParts, 0, 0, 3),
clampInteger(save.completedRaidPhases, 0, 0, 3), clampInteger(save.completedRaidPhases, 0, 0, 3),
sqliteDateFromMs(save.updatedAt),
accountId, accountId,
) )
@@ -1129,6 +1229,10 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
INSERT INTO character_inventory (character_id, item_id, quantity, equipped) INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`) `)
const insertBossStats = database.prepare(`
INSERT INTO character_boss_stats (character_id, encounter_id, kills, pet_quantity)
VALUES (?, ?, ?, ?)
`)
for (const classId of classIds) { for (const classId of classIds) {
const local = save.characters[classId] const local = save.characters[classId]
@@ -1193,6 +1297,14 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
DELETE FROM character_inventory DELETE FROM character_inventory
WHERE character_id = ? WHERE character_id = ?
`).run(characterId) `).run(characterId)
database.prepare(`
DELETE FROM character_boss_stats
WHERE character_id = ?
`).run(characterId)
database.prepare(`
DELETE FROM character_pvp_stats
WHERE character_id = ?
`).run(characterId)
const inventoryByItemId = new Map() const inventoryByItemId = new Map()
const equippedSlots = new Set() const equippedSlots = new Set()
for (const item of Array.isArray(local.inventory) ? local.inventory : []) { for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
@@ -1215,6 +1327,34 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
for (const [itemId, itemState] of inventoryByItemId) { for (const [itemId, itemState] of inventoryByItemId) {
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0) insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
} }
if (classId === Number(save.activeClassId)) {
const bossKills = save.bossKills && typeof save.bossKills === 'object' ? save.bossKills : {}
const bossPets = save.bossPets && typeof save.bossPets === 'object' ? save.bossPets : {}
for (const [encounterId, rawKills] of Object.entries(bossKills)) {
const id = Number(encounterId)
if (!Number.isInteger(id)) continue
insertBossStats.run(
characterId,
id,
clampInteger(rawKills, 0, 0, 1000000),
clampInteger(bossPets[encounterId], 0, 0, 1000000),
)
}
for (const [encounterId, rawPets] of Object.entries(bossPets)) {
if (Object.hasOwn(bossKills, encounterId)) continue
const id = Number(encounterId)
if (!Number.isInteger(id)) continue
insertBossStats.run(characterId, id, 0, clampInteger(rawPets, 0, 0, 1000000))
}
database.prepare(`
INSERT INTO character_pvp_stats (character_id, matches_played, matches_won)
VALUES (?, ?, ?)
`).run(
characterId,
clampInteger(save.pvpMatchesPlayed, 0, 0, 1000000),
clampInteger(save.pvpMatchesWon, 0, 0, 1000000),
)
}
} }
let syncedClassId = clampInteger( let syncedClassId = clampInteger(
@@ -1327,17 +1467,6 @@ function formatLootRoll(database, context, record, dropChance) {
} }
} }
function coinDropQuantity() {
const roll = Math.random()
if (roll < 0.15) return 3
if (roll < 0.5) return 2
return 1
}
function roguelikeCoinItemLevel(stage) {
return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5)
}
function awardRoguelikeCoin(database, characterId, sourceEncounterId, stage) { function awardRoguelikeCoin(database, characterId, sourceEncounterId, stage) {
if (!sourceEncounterId || !stage) return null if (!sourceEncounterId || !stage) return null
const coin = database.prepare(` const coin = database.prepare(`
@@ -1739,6 +1868,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)
@@ -1792,24 +1924,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(`
@@ -1823,6 +1970,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)
@@ -2267,7 +2418,18 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3)) const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3))
const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level' const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level'
? 'pvp-boss-quarter-level' ? 'pvp-boss-quarter-level'
: 'default' : runMetrics?.experienceMode === 'pvp-fight-twelfth-level'
? 'pvp-fight-twelfth-level'
: runMetrics?.experienceMode === 'pvp-match-win-half-level'
? 'pvp-match-win-half-level'
: runMetrics?.experienceMode === 'pvp-stadium-round-win-quarter-level'
? 'pvp-stadium-round-win-quarter-level'
: runMetrics?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
? 'pvp-stadium-round-loss-tenth-level'
: runMetrics?.experienceMode === 'pvp-stadium-match-half-level'
? 'pvp-stadium-match-half-level'
: 'default'
const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared)
const resourceSpent = Number(runMetrics?.resourceSpent) const resourceSpent = Number(runMetrics?.resourceSpent)
const durationSeconds = Number(runMetrics?.durationSeconds) const durationSeconds = Number(runMetrics?.durationSeconds)
if (!Number.isInteger(dungeonId) || dungeonId < 1) { if (!Number.isInteger(dungeonId) || dungeonId < 1) {
@@ -2282,6 +2444,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) { if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) {
throw new Error('The roguelike boss total is invalid.') throw new Error('The roguelike boss total is invalid.')
} }
if (!Number.isInteger(fightsCleared) || fightsCleared < 0 || fightsCleared > 100000) {
throw new Error('The roguelike fight total is invalid.')
}
if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) { if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
throw new Error('The run resource total is invalid.') throw new Error('The run resource total is invalid.')
} }
@@ -2334,14 +2499,15 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
`).get(maxLevel).experienceRequired `).get(maxLevel).experienceRequired
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' || experienceMode === 'pvp-fight-twelfth-level') {
const catchUpTargetLevel = database.prepare(` const catchUpTargetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level SELECT COALESCE(MAX(level), 0) AS level
FROM characters FROM characters
WHERE account_id = ? WHERE account_id = ?
AND id != ? AND id != ?
`).get(accountId, characterId).level `).get(accountId, characterId).level
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) { const rewardUnits = experienceMode === 'pvp-boss-quarter-level' ? bossesCleared : fightsCleared
for (let rewardIndex = 0; rewardIndex < rewardUnits && newExperience < maxExperience; rewardIndex += 1) {
const currentLevelFloor = database.prepare(` const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired SELECT experience_required AS experienceRequired
FROM level_progression FROM level_progression
@@ -2355,7 +2521,9 @@ 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)
const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25 const rewardRate = experienceMode === 'pvp-boss-quarter-level'
? (catchUpTargetLevel > newLevel ? 0.5 : 0.25)
: (catchUpTargetLevel > newLevel ? 1 / 6 : 1 / 12)
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate)) 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
@@ -2363,6 +2531,36 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE experience_required <= ? WHERE experience_required <= ?
`).get(newExperience).level `).get(newExperience).level
} }
} else if (
experienceMode === 'pvp-match-win-half-level'
|| experienceMode === 'pvp-stadium-round-win-quarter-level'
|| experienceMode === 'pvp-stadium-round-loss-tenth-level'
|| experienceMode === 'pvp-stadium-match-half-level'
) {
const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(newLevel).experienceRequired
const nextLevelExperience = newLevel >= maxLevel
? maxExperience
: database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(newLevel + 1).experienceRequired
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
const rewardRate = experienceMode === 'pvp-stadium-round-win-quarter-level'
? 0.25
: experienceMode === 'pvp-stadium-round-loss-tenth-level'
? 0.1
: 0.5
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
newLevel = database.prepare(`
SELECT MAX(level) AS level
FROM level_progression
WHERE experience_required <= ?
`).get(newExperience).level
} else { } else {
const baseExperienceReward = Math.round( const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3), dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
@@ -2505,6 +2703,261 @@ function saveProfile(database, characterId, accountId, payload) {
} }
} }
function cleanupPvpMemory(now = Date.now()) {
for (const [ticketId, ticket] of pvpQueue.entries()) {
if (now - ticket.updatedAt > pvpQueueTtlMs) pvpQueue.delete(ticketId)
}
for (const [matchId, match] of pvpMatches.entries()) {
if (now - match.updatedAt > pvpMatchTtlMs) pvpMatches.delete(matchId)
}
}
function validatePvpContentType(value) {
if (value !== 'dungeon' && value !== 'raid' && value !== 'stadium') {
throw new Error('The PvP content type is invalid.')
}
return value
}
function validatePvpStartStage(value) {
const startStage = Number(value)
if (!Number.isInteger(startStage) || startStage < 1 || startStage > 1000) {
throw new Error('The PvP start stage is invalid.')
}
return startStage
}
function pvpPlayerInfo(session) {
return {
accountId: session.accountId,
characterId: session.characterId,
characterName: session.characterName,
className: session.className,
}
}
function pvpSnapshot(match) {
return {
id: match.id,
contentType: match.contentType,
startStage: match.startStage,
createdAt: match.createdAt,
players: match.players,
states: match.states,
statuses: match.statuses,
progress: match.progress,
upgradeChoices: match.upgradeChoices,
rematchRequests: match.rematchRequests,
updatedAt: match.updatedAt,
}
}
function createPvpMatch(contentType, startStage, players, now = Date.now()) {
const matchId = randomBytes(12).toString('base64url')
const match = {
id: matchId,
contentType,
startStage,
createdAt: now,
players,
states: {},
statuses: {},
progress: {},
upgradeChoices: {},
rematchRequests: {},
updatedAt: now,
}
pvpMatches.set(matchId, match)
return match
}
function joinPvpQueue(session, payload) {
const now = Date.now()
cleanupPvpMemory(now)
const contentType = validatePvpContentType(payload.contentType)
const startStage = validatePvpStartStage(payload.startStage)
const existingTicket = [...pvpQueue.values()].find((ticket) =>
ticket.accountId === session.accountId
&& ticket.characterId === session.characterId
&& ticket.contentType === contentType
&& ticket.startStage === startStage
)
if (existingTicket?.matchId) {
const match = pvpMatches.get(existingTicket.matchId)
if (match) {
const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
return { ticketId: existingTicket.id, status: 'matched', side, match: pvpSnapshot(match) }
}
}
const opponent = [...pvpQueue.values()]
.filter((ticket) =>
!ticket.matchId
&& ticket.contentType === contentType
&& ticket.startStage === startStage
&& ticket.accountId !== session.accountId
)
.sort((left, right) => left.createdAt - right.createdAt)[0]
const player = pvpPlayerInfo(session)
if (opponent) {
const match = createPvpMatch(contentType, startStage, {
a: { side: 'a', ...opponent.player },
b: { side: 'b', ...player },
}, now)
opponent.matchId = match.id
opponent.updatedAt = now
const ticketId = randomBytes(12).toString('base64url')
pvpQueue.set(ticketId, {
id: ticketId,
accountId: session.accountId,
characterId: session.characterId,
contentType,
startStage,
player,
matchId: match.id,
createdAt: now,
updatedAt: now,
})
return { ticketId, status: 'matched', side: 'b', match: pvpSnapshot(match) }
}
if (existingTicket) {
existingTicket.updatedAt = now
return { ticketId: existingTicket.id, status: 'waiting' }
}
const ticketId = randomBytes(12).toString('base64url')
pvpQueue.set(ticketId, {
id: ticketId,
accountId: session.accountId,
characterId: session.characterId,
contentType,
startStage,
player,
createdAt: now,
updatedAt: now,
})
return { ticketId, status: 'waiting' }
}
function checkPvpQueue(session, ticketId) {
cleanupPvpMemory()
const ticket = pvpQueue.get(ticketId)
if (!ticket || ticket.accountId !== session.accountId) {
const error = new Error('PvP queue ticket not found.')
error.status = 404
throw error
}
ticket.updatedAt = Date.now()
if (!ticket.matchId) return { ticketId, status: 'waiting' }
const match = pvpMatches.get(ticket.matchId)
if (!match) return { ticketId, status: 'waiting' }
const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
return { ticketId, status: 'matched', side, match: pvpSnapshot(match) }
}
function cancelPvpQueue(session, ticketId) {
const ticket = pvpQueue.get(ticketId)
if (ticket && ticket.accountId === session.accountId && !ticket.matchId) {
pvpQueue.delete(ticketId)
}
return { ok: true }
}
function requirePvpMatchForSession(session, matchId) {
cleanupPvpMemory()
const match = pvpMatches.get(matchId)
if (!match) {
const error = new Error('PvP match not found.')
error.status = 404
throw error
}
const side = match.players.a.accountId === session.accountId ? 'a'
: match.players.b.accountId === session.accountId ? 'b'
: null
if (!side) {
const error = new Error('That PvP match belongs to another account.')
error.status = 403
throw error
}
return { match, side }
}
function updatePvpMatchState(session, matchId, payload) {
const { match, side } = requirePvpMatchForSession(session, matchId)
const status = ['playing', 'upgrade-choice', 'shop', 'won', 'lost'].includes(payload.status)
? payload.status
: 'playing'
const progress = {
stage: validatePvpStartStage(payload.stage),
encounterIndex: Math.max(0, Math.floor(Number(payload.encounterIndex) || 0)),
encountersCleared: Math.max(0, Math.floor(Number(payload.encountersCleared) || 0)),
enemyHealth: Math.max(0, Number(payload.enemyHealth) || 0),
alive: Boolean(payload.alive),
elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)),
}
const currentProgress = match.progress[side]
if (
currentProgress
&& (
progress.stage < currentProgress.stage
|| (
progress.stage === currentProgress.stage
&& progress.encounterIndex < currentProgress.encounterIndex
)
)
) {
return pvpSnapshot(match)
}
match.states[side] = payload.state ?? null
match.statuses[side] = status
match.progress[side] = progress
match.updatedAt = Date.now()
return pvpSnapshot(match)
}
function submitPvpUpgradeChoice(session, matchId, payload) {
const { match, side } = requirePvpMatchForSession(session, matchId)
const encounterIndex = Math.max(0, Math.floor(Number(payload.encounterIndex) || 0))
if (!match.upgradeChoices[side]) match.upgradeChoices[side] = {}
match.upgradeChoices[side][String(encounterIndex)] = {
encounterIndex,
buffId: String(payload.buffId ?? ''),
debuffId: String(payload.debuffId ?? ''),
purchases: Array.isArray(payload.purchases) ? payload.purchases.map((purchase) => String(purchase)) : [],
shopReady: Boolean(payload.shopReady),
}
match.updatedAt = Date.now()
return pvpSnapshot(match)
}
function requestPvpRematch(session, matchId) {
const { match, side } = requirePvpMatchForSession(session, matchId)
if (match.nextMatchId) {
const nextMatch = pvpMatches.get(match.nextMatchId)
if (nextMatch) return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
}
match.rematchRequests = match.rematchRequests ?? {}
match.rematchRequests[side] = true
match.updatedAt = Date.now()
const opponentSide = side === 'a' ? 'b' : 'a'
if (!match.rematchRequests[opponentSide]) {
return { status: 'waiting', match: pvpSnapshot(match), side }
}
const nextMatch = createPvpMatch(
match.contentType,
match.startStage,
{
a: match.players.a,
b: match.players.b,
},
Date.now(),
)
match.nextMatchId = nextMatch.id
match.updatedAt = Date.now()
return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
}
export function gameApiPlugin() { export function gameApiPlugin() {
return { return {
name: 'ashen-halls-game-api', name: 'ashen-halls-game-api',
@@ -2596,6 +3049,7 @@ export async function handleAuthApiRequest(request, response, next = null) {
const database = new DatabaseSync(databasePath) const database = new DatabaseSync(databasePath)
database.exec('PRAGMA foreign_keys = ON') database.exec('PRAGMA foreign_keys = ON')
ensureRuntimeMigrations(database)
try { try {
const ip = requestIp(request) const ip = requestIp(request)
@@ -2652,10 +3106,11 @@ export async function handleApiRequest(request, response, next) {
const database = new DatabaseSync(databasePath) const database = new DatabaseSync(databasePath)
database.exec('PRAGMA foreign_keys = ON') database.exec('PRAGMA foreign_keys = ON')
ensureRuntimeMigrations(database)
try { try {
const ip = requestIp(request) const ip = requestIp(request)
consumeRateLimit(`api:${ip}`, 240, 60 * 1000) consumeRateLimit(`api:${ip}`, 900, 60 * 1000)
database.prepare(` database.prepare(`
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
`).run() `).run()
@@ -2664,6 +3119,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') {
@@ -2685,16 +3145,60 @@ export async function handleApiRequest(request, response, next) {
if (request.url === '/api/profile' && request.method === 'PUT') { if (request.url === '/api/profile' && request.method === 'PUT') {
const payload = await readJson(request) const payload = await readJson(request)
const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload) const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload)
sendJson(response, 200, getProfile(database, newCharacterId, session.accountId)) sendMutatingResult(response, database, session.accountId, getProfile(database, newCharacterId, session.accountId))
return
}
if (request.url === '/api/pvp/queue' && request.method === 'POST') {
const payload = await readJson(request)
sendJson(response, 200, joinPvpQueue(session, payload))
return
}
const pvpQueueTicket = request.url.match(/^\/api\/pvp\/queue\/([A-Za-z0-9_-]+)$/)
if (pvpQueueTicket && request.method === 'GET') {
sendJson(response, 200, checkPvpQueue(session, pvpQueueTicket[1]))
return
}
if (pvpQueueTicket && request.method === 'DELETE') {
sendJson(response, 200, cancelPvpQueue(session, pvpQueueTicket[1]))
return
}
const pvpMatchState = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/state$/)
if (pvpMatchState && request.method === 'POST') {
const payload = await readJson(request, 128 * 1024)
sendJson(response, 200, updatePvpMatchState(session, pvpMatchState[1], payload))
return
}
const pvpUpgradeChoice = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/upgrade-choice$/)
if (pvpUpgradeChoice && request.method === 'POST') {
const payload = await readJson(request)
sendJson(response, 200, submitPvpUpgradeChoice(session, pvpUpgradeChoice[1], payload))
return
}
const pvpRematch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/)
if (pvpRematch && request.method === 'POST') {
sendJson(response, 200, requestPvpRematch(session, pvpRematch[1]))
return
}
const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/)
if (pvpMatch && request.method === 'GET') {
sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match))
return return
} }
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/) const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
if (dungeonCompletion && request.method === 'POST') { if (dungeonCompletion && request.method === 'POST') {
const payload = await readJson(request) const payload = await readJson(request)
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
completeDungeon( completeDungeon(
database, database,
session.characterId, session.characterId,
@@ -2709,9 +3213,10 @@ export async function handleApiRequest(request, response, next) {
if (request.url === '/api/roguelike/complete' && request.method === 'POST') { if (request.url === '/api/roguelike/complete' && request.method === 'POST') {
const payload = await readJson(request) const payload = await readJson(request)
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
completeRoguelike(database, session.characterId, session.accountId, payload), completeRoguelike(database, session.characterId, session.accountId, payload),
) )
return return
@@ -2719,24 +3224,26 @@ export async function handleApiRequest(request, response, next) {
const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/) const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/)
if (talentAllocation && request.method === 'POST') { if (talentAllocation && request.method === 'POST') {
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
allocateTalent(database, session.characterId, Number(talentAllocation[1])), allocateTalent(database, session.characterId, Number(talentAllocation[1])),
) )
return return
} }
if (request.url === '/api/talents/reset' && request.method === 'POST') { if (request.url === '/api/talents/reset' && request.method === 'POST') {
sendJson(response, 200, resetTalents(database, session.characterId)) sendMutatingResult(response, database, session.accountId, resetTalents(database, session.characterId))
return return
} }
const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/) const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/)
if (itemEquip && request.method === 'POST') { if (itemEquip && request.method === 'POST') {
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
equipItem(database, session.characterId, Number(itemEquip[1])), equipItem(database, session.characterId, Number(itemEquip[1])),
) )
return return
@@ -2744,9 +3251,10 @@ export async function handleApiRequest(request, response, next) {
const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/) const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/)
if (itemDiscard && request.method === 'POST') { if (itemDiscard && request.method === 'POST') {
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
discardExtraItem(database, session.characterId, Number(itemDiscard[1])), discardExtraItem(database, session.characterId, Number(itemDiscard[1])),
) )
return return
@@ -2754,9 +3262,10 @@ export async function handleApiRequest(request, response, next) {
const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/) const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/)
if (itemBreakdown && request.method === 'POST') { if (itemBreakdown && request.method === 'POST') {
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
breakdownItem(database, session.characterId, Number(itemBreakdown[1])), breakdownItem(database, session.characterId, Number(itemBreakdown[1])),
) )
return return
@@ -2764,9 +3273,10 @@ export async function handleApiRequest(request, response, next) {
const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/) const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/)
if (recipeCraft && request.method === 'POST') { if (recipeCraft && request.method === 'POST') {
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
craftItem(database, session.characterId, Number(recipeCraft[1])), craftItem(database, session.characterId, Number(recipeCraft[1])),
) )
return return
@@ -2774,9 +3284,10 @@ export async function handleApiRequest(request, response, next) {
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/) const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
if (itemUpgrade && request.method === 'POST') { if (itemUpgrade && request.method === 'POST') {
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
upgradeItem(database, session.characterId, Number(itemUpgrade[1])), upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
) )
return return
@@ -2785,9 +3296,10 @@ export async function handleApiRequest(request, response, next) {
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/) const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
if (encounterLootRoll && request.method === 'POST') { if (encounterLootRoll && request.method === 'POST') {
const payload = await readJson(request) const payload = await readJson(request)
sendJson( sendMutatingResult(
response, response,
200, database,
session.accountId,
rollEncounterLoot( rollEncounterLoot(
database, database,
session.characterId, session.characterId,
+3865 -213
View File
File diff suppressed because it is too large Load Diff
+81 -934
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import './App.css' import './App.css'
import { AdminScreen } from './components/AdminScreen' import { AdminScreen } from './components/AdminScreen'
import { InputProvider } from './input'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<AdminScreen onBack={() => window.close()} /> <InputProvider>
<AdminScreen onBack={() => window.close()} />
</InputProvider>
</StrictMode>, </StrictMode>,
) )
+116
View File
@@ -0,0 +1,116 @@
# Combat Modules
Shared combat code is split by responsibility:
- `spellEffects.ts` owns spell target plans and the shared spell-effect applier.
- `combatEngine.ts` owns repeated per-member tick math: damage reduction, shields, HoTs, poison, debuff timers, and bounce-heal jumps.
- `combatPresentation.ts` owns UI-facing caps and grouping for combat logs and floating combat text.
- Screen files provide `SpellEffectProfile` objects for mode-specific numbers.
- `spellCasting.ts` owns cast readiness and cooldown writes.
- `pvpSpellCasting.ts` owns the shared PvP cast pipeline: readiness validation, live target validation, effect application, floating-heal emission, and side-state writes.
- `combatTick.ts` owns small tick helpers such as resource regeneration and cooldown decay.
- `cpuAi.ts` owns CPU healer timing, mistake rolls, spell choice, and turn execution.
- `stackCounts.ts` owns stacked buff/upgrade count maps and summaries.
- `spellModifiers.ts` owns stack-based spell modifiers such as cost, cooldown, extra targets, free casts, and power multipliers.
- `combatStateTransitions.ts` owns shared combat state writes such as applying a completed cast to party, resource, cooldown, and free-cast fields.
- `stadiumLifecycle.ts` owns pure Stadium round/shop decisions: round outcome resolution, shop point awards, and CPU purchase selection.
- `pvpRoguelikeLifecycle.ts` owns pure PvP roguelike lifecycle decisions: live snapshot outcomes, combat tick outcomes, upgrade-complete checks, and next-stage progression.
- `pvpRoguelikeUpgrades.ts` owns PvP roguelike upgrade application, revive cleanup, party recovery, resource restore, cooldown reset, and next enemy health setup.
- `pvpRoguelikeMatchSetup.ts` owns PvP roguelike match-start side creation, first segment setup, live opponent naming, and shared reset defaults.
- `stadiumMatchSetup.ts` owns Stadium starter side creation, local/live match setup, live opponent naming, and shared reset defaults.
- `pveRoguelikeRunSetup.ts` owns PvE combat-state creation and roguelike run reset defaults.
- `rewardSummaries.ts` owns reward summary initialization, unlocked ability merging, XP/level/talent accumulation, and PvP roguelike boss loot accumulation.
- `dualScreenPayloads.ts` owns dual-screen combat payload construction and mode-specific status normalization.
- `pvpLiveLifecycle.ts` owns live PvP polling, local progress publication, and rematch request state.
## SpellEffectProfile
Use a profile when spell behavior is the same shape but different by mode.
Examples:
- PvE stores Renew-style effects in `hotEffects`; PvP modes mostly use `hotTicks`.
- Stadium applies dampening through profile power callbacks.
- PvE cleanse intentionally does not emit floating heal text; PvP modes do.
- Shield ratios such as Radiance `0.3`, Mend `0.5`, and Stadium group shield `0.5` belong in profiles.
When adding a spell rule, prefer adding a named profile field or callback instead of branching in screen components.
## StackCounts
Use `createStackCounts` once per party state, upgrade list, or cast snapshot when repeated math needs stack totals. Then pass the map to spell cost, cooldown, target, and effect helpers. This keeps stacked upgrade behavior consistent and avoids repeated `filter`/scan work in combat render and cast paths.
## Spell Modifiers
Use `spellModifiers.ts` for stacked upgrade math instead of open-coded exponent formulas in screen components. Screens still own mode-specific IDs, but shared helpers own the math:
- slot cost/cooldown reducers default to `0.75 ** stacks`.
- opponent slot penalties default to `1.25 ** stacks`.
- power boosts default to `1.25 ** stacks`.
- free-cast checks use a named stack modifier and the mode's existing `freeCastReady` rules.
## State Transitions
Use `applyCastStateUpdate` after a spell effect profile returns the next party. Screens can still prepare mode-specific cooldown adjustments before the spell cooldown lands, but the final resource spend, cooldown write, party replacement, and free-cast progress should go through the transition helper.
## PvP Spell Casting
Use `applyPvpSpellCast` after a PvP screen builds its mode-specific target plan and `SpellEffectProfile`. Keep balance inputs such as roguelike effects, Stadium dampening, shop buffs, cooldown tweaks, and free-cast flags in the screen; let the helper own the repeated validate/apply/emit/write pipeline.
## CPU AI
Use `runCpuHealTurn` for PvP CPU turns. Screens still own mode-specific behavior tables and apply-spell adapters, while `cpuAi.ts` owns timing checks, mistake rolls, action selection, and invoking the selected spell.
## Stadium Lifecycle
Use `resolveStadiumRound` for win/loss/tie decisions instead of open-coding score checks in `PvpStadiumScreen`. Use `stadiumShopPointsForOutcome` and `chooseStadiumCpuPurchases` for shop setup so player and CPU point rules stay in one place.
## PvP Roguelike Lifecycle
Use `resolvePvpRoguelikeCombatOutcome` after each tick advances both sides. Use `resolvePvpRoguelikeLiveSnapshot` when a live match snapshot arrives. Use `nextPvpRoguelikeStage` after upgrade choices to decide whether to continue, advance stage, or complete the match. Components should execute the returned intent with React setters instead of duplicating the decision tree.
## PvP Roguelike Upgrades
Use `applyPvpRoguelikeUpgradeChoice` to apply selected buffs, incoming debuffs, and revive-buff debuff cleanup. Use `preparePvpRoguelikeNextEncounter` after progression chooses the next encounter so recovery, 25% resource restore, cooldown reset, and enemy health setup stay consistent between live and CPU matches.
## PvP Roguelike Match Setup
Use `createPvpRoguelikeMatchStart` for local/CPU starts and `createPvpRoguelikeLiveMatchStart` for live matches. These helpers build starter sides, set first encounter health, name live opponents, and provide reset defaults so rematch, checkpoint start, and queue fallback do not duplicate initial state construction.
## Stadium Match Setup
Use `createStadiumMatchStart` for queue/CPU starts, `createStadiumLiveMatchStart` for live matches, and `createStadiumStarterSide` for advancing to the next round. These helpers reset party state, carry purchased buffs and round wins where needed, and provide shared reset defaults for queue, rematch, and live match starts.
## PvE Run Setup
Use `createPveCombatState` for initial single-player combat state and `createPveRoguelikeRunStart` when resetting a run. This keeps party cloning, resource setup, enemy health, cooldown/free-cast reset, and UI reset defaults in one place.
## Reward Summaries
Use `createEmptyRewardSummary`, `createEmptyPvpRunSummary`, `mergeDungeonRewardSummary`, and `mergePvpRunRewardSummary` instead of open-coding reward accumulation in screens. This keeps XP, level, talent points, unlocked abilities, and boss loot merge behavior consistent.
Presentation components for these summaries live in `components/RewardPanels.tsx`: use `RewardXpSummary`, `BonusItemReward`, `LootRollList`, and `PvpRunLootList` instead of duplicating reward JSX in result screens.
## Dual-Screen Payloads
Use the builders in `dualScreenPayloads.ts` when publishing combat state to the secondary display. Keep render-derived arrays such as stripped floating text and opponent summaries memoized before calling the builder, then let the builder normalize mode status values like queueing, countdown, and Stadium shop into the bottom-screen status model.
## Live PvP Sync
Use `usePvpLiveMatchSync` for live match polling and rematch controls. Screens should provide only mode-specific progress payloads and opponent snapshot rules; the hook owns publish/poll intervals, rematch pending state, expiry, and error messages.
## Timers
Use `useRoundCountdown` for PvP round starts and `useDeadlineTimer` for mode deadlines such as PvP roguelike upgrade choice and Stadium shop. Keep countdown expiry behavior mode-specific, but avoid open-coded interval refs and `Date.now()` loops in screens.
## Floating Combat Text
Use `useFloatingCombatText` for single-sided combat screens and `useSidedFloatingCombatText` for PvP screens. The hooks own IDs, expiry cleanup, member grouping, side filtering, and dual-screen text stripping so screens only emit heal events.
## Party Targeting
Use `usePartyTargeting` for combat-screen navigation wrappers. Keep raw grid/direct selection rules in `combat/targeting.ts`; the hook owns relative, directional, and direct target callbacks around the current party ref, selected-id ref, grid columns, living-target flags, and target group.
## Render Allocation
Keep repeated combat-render derivations memoized near their source state: active input bindings, stack summaries, visible shop choices, enemy health segments, and stripped floating text payloads. Avoid calling summary helpers or `bindings[lastDevice]` repeatedly inside JSX loops.
+170
View File
@@ -0,0 +1,170 @@
import type { PartyMember } from '../game'
import {
clamp,
healAmount,
memberHotEffects,
tickHotEffects,
} from './rules'
export type MemberTickInput = {
member: PartyMember
party: PartyMember[]
damage: number
hotHealing: number
hotTicks: 'effects' | 'ticks'
healingMultiplier?: number
damageReductionMultiplier?: number
damageReductionRounding?: 'round' | 'ceil'
shieldedDamageMultiplier?: number
applyDebuff?: {
label: string
ticks: number
}
applyPoisonStacks?: boolean
poisonDamage?: (stacks: number) => number
applyMaxHealthPenaltyTicks?: number
applyHealingReductionTicks?: number
decrementDamageReduction?: boolean
useBounceHeals?: boolean
jumpTarget?: () => PartyMember | undefined
}
export type MemberTickResult = {
member: PartyMember
floatingHeal: number
jumpedBounceHeals: Array<{
targetId: string
heal: NonNullable<PartyMember['bounceHeals']>[number]
}>
}
/**
* Applies one combat tick to a party member. Screen-level engines still decide
* who is targeted and when fights end; this helper owns repeated health math:
* damage reduction, shields, HoT healing, debuff timers, poison, and bounce heals.
*/
export function advanceMemberTick({
member,
party,
damage,
hotHealing,
hotTicks,
healingMultiplier = 1,
damageReductionMultiplier = 0.5,
damageReductionRounding = 'round',
shieldedDamageMultiplier,
applyDebuff,
applyPoisonStacks = false,
poisonDamage = () => 0,
applyMaxHealthPenaltyTicks,
applyHealingReductionTicks,
decrementDamageReduction = false,
useBounceHeals = false,
jumpTarget,
}: MemberTickInput): MemberTickResult {
if (member.health <= 0) {
return { member, floatingHeal: 0, jumpedBounceHeals: [] }
}
let nextDamage = damage
const nextPoisonStacks = applyPoisonStacks
? Math.max(1, (member.poisonStacks ?? 0) + 1)
: member.poisonStacks ?? 0
if (nextPoisonStacks > 0) nextDamage += poisonDamage(nextPoisonStacks)
if ((member.damageReductionTicks ?? 0) > 0) {
const reducedDamage = nextDamage * damageReductionMultiplier
nextDamage = damageReductionRounding === 'ceil'
? Math.ceil(reducedDamage)
: Math.round(reducedDamage)
}
if (member.shield > 0 && shieldedDamageMultiplier !== undefined) {
nextDamage = Math.round(nextDamage * shieldedDamageMultiplier)
}
const absorbed = Math.min(member.shield, nextDamage)
const hotEffects = hotTicks === 'effects' ? memberHotEffects(member) : []
let healing = hotTicks === 'effects'
? hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0)
: member.hotTicks > 0 ? healAmount(member, hotHealing, healingMultiplier) : 0
let nextBounceHeals = [...(member.bounceHeals ?? [])]
const jumpedBounceHeals: MemberTickResult['jumpedBounceHeals'] = []
if (useBounceHeals && nextDamage > 0 && nextBounceHeals.length > 0) {
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
healing += healAmount(member, effect.power, healingMultiplier)
const nextCharges = effect.charges - 1
if (nextCharges <= 0) return []
const target = jumpTarget?.() ?? party.find((candidate) => candidate.health > 0 && candidate.id !== member.id) ?? member
jumpedBounceHeals.push({
targetId: target.id,
heal: { ...effect, charges: nextCharges },
})
return []
})
}
const nextMaxHealthPenaltyTicks = applyMaxHealthPenaltyTicks !== undefined
? applyMaxHealthPenaltyTicks
: Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1)
const nextHealingReductionTicks = applyHealingReductionTicks !== undefined
? applyHealingReductionTicks
: Math.max(0, (member.healingReductionTicks ?? 0) - 1)
const nextDebuffTicks = applyDebuff
? applyDebuff.ticks
: Math.max(0, (member.debuffTicks ?? 0) - 1)
const nextEffectiveMaxHealth = Math.max(1, Math.round(
member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1),
))
return {
member: {
...member,
health: clamp(
clamp(member.health + healing, 0, nextEffectiveMaxHealth) - nextDamage + absorbed,
0,
nextEffectiveMaxHealth,
),
shield: Math.max(0, member.shield - nextDamage),
hotTicks: hotTicks === 'effects' ? 0 : Math.max(0, member.hotTicks - 1),
hotEffects: hotTicks === 'effects' ? tickHotEffects(hotEffects) : member.hotEffects,
bounceHeals: useBounceHeals ? nextBounceHeals : member.bounceHeals,
damageReductionTicks: decrementDamageReduction
? Math.max(0, (member.damageReductionTicks ?? 0) - 1)
: member.damageReductionTicks,
debuff: nextDebuffTicks > 0
? applyDebuff?.label ?? member.debuff
: undefined,
debuffTicks: nextDebuffTicks > 0 ? nextDebuffTicks : undefined,
poisonStacks: nextPoisonStacks,
maxHealthPenaltyTicks: nextMaxHealthPenaltyTicks,
healingReductionTicks: nextHealingReductionTicks,
},
floatingHeal: healing,
jumpedBounceHeals,
}
}
export function attachJumpedBounceHeals(
party: PartyMember[],
jumpedBounceHeals: MemberTickResult['jumpedBounceHeals'],
) {
if (jumpedBounceHeals.length === 0) return party
const jumpedByTarget = new Map<string, NonNullable<PartyMember['bounceHeals']>>()
for (const jump of jumpedBounceHeals) {
const current = jumpedByTarget.get(jump.targetId)
if (current) current.push(jump.heal)
else jumpedByTarget.set(jump.targetId, [jump.heal])
}
return party.map((member) => {
const jumped = jumpedByTarget.get(member.id)
if (!jumped || jumped.length === 0) return member
return {
...member,
bounceHeals: [
...(member.bounceHeals ?? []),
...jumped,
],
}
})
}
+41
View File
@@ -0,0 +1,41 @@
import type { CombatLogEntry } from '../game'
export type BasicFloatingCombatText = {
id: number
memberId: string
value: number
}
export const DEFAULT_COMBAT_LOG_LIMIT = 60
export const STADIUM_COMBAT_LOG_LIMIT = 70
export const DEFAULT_FLOATING_TEXT_LIMIT = 48
export function appendCombatLog(
current: CombatLogEntry[],
entry: CombatLogEntry,
limit = DEFAULT_COMBAT_LOG_LIMIT,
) {
return [entry, ...current].slice(0, limit)
}
export function appendFloatingText<T extends BasicFloatingCombatText>(
current: T[],
entry: T,
limit = DEFAULT_FLOATING_TEXT_LIMIT,
) {
return [...current, entry].slice(-limit)
}
export function groupFloatingTextsByMember<T extends BasicFloatingCombatText>(texts: T[]) {
const groups = new Map<string, T[]>()
texts.forEach((entry) => {
const current = groups.get(entry.memberId)
if (current) current.push(entry)
else groups.set(entry.memberId, [entry])
})
return groups
}
export function stripFloatingTextSide<T extends BasicFloatingCombatText>(texts: T[]): BasicFloatingCombatText[] {
return texts.map(({ id, memberId, value }) => ({ id, memberId, value }))
}
+50
View File
@@ -0,0 +1,50 @@
import type { PartyMember, Spell } from '../game'
import { putSpellOnCooldown } from './spellCasting'
import { advanceFreeCastProgress } from './spellEffects'
export type CastStateFields<TParty extends readonly PartyMember[] = PartyMember[]> = {
party: TParty
resource: number
cooldowns: Record<string, number>
castsTowardFree: number
freeCastReady: boolean
}
export function applyCastStateUpdate<
TParty extends readonly PartyMember[],
TState extends CastStateFields<TParty>,
>({
current,
party,
spell,
resourceCost,
cooldownMultiplier = 1,
cooldowns = current.cooldowns,
freeCast = { enabled: false, wasReady: false },
}: {
current: TState
party: TParty
spell: Spell
resourceCost: number
cooldownMultiplier?: number
cooldowns?: Record<string, number>
freeCast?: {
enabled: boolean
wasReady: boolean
}
}) {
const freeCastProgress = advanceFreeCastProgress({
enabled: freeCast.enabled,
wasReady: freeCast.wasReady,
castsTowardFree: current.castsTowardFree,
})
return {
...current,
party,
resource: current.resource - resourceCost,
cooldowns: putSpellOnCooldown(cooldowns, spell, cooldownMultiplier),
castsTowardFree: freeCastProgress.castsTowardFree,
freeCastReady: freeCastProgress.freeCastReady,
}
}
+17
View File
@@ -0,0 +1,17 @@
import { clamp } from './rules'
export function tickSeconds(tickMs: number) {
return tickMs / 1000
}
export function advanceCooldowns(cooldowns: Record<string, number>, elapsedSeconds: number) {
const nextCooldowns: Record<string, number> = {}
for (const id in cooldowns) {
nextCooldowns[id] = Math.max(0, cooldowns[id] - elapsedSeconds)
}
return nextCooldowns
}
export function regenerateResource(resource: number, amount: number, maxResource: number) {
return clamp(resource + amount, 0, maxResource)
}
+112
View File
@@ -0,0 +1,112 @@
import type { PartyMember, Spell } from '../game'
import { effectiveMaxHealth } from './rules'
export type CpuHealBehavior = {
directHealThreshold: number
groupHealThreshold: number
hotThreshold: number
shieldThreshold: number
}
export type CpuTurnBehavior = CpuHealBehavior & {
actionEveryTicks: number
mistakeChance: number
}
export function shouldCpuAct({
elapsedTicks,
behavior,
}: {
elapsedTicks: number
behavior: CpuTurnBehavior
}) {
return elapsedTicks % behavior.actionEveryTicks === 0 && Math.random() >= behavior.mistakeChance
}
export function chooseCpuHealActions({
party,
spells,
behavior,
preferSlots = false,
}: {
party: PartyMember[]
spells: Spell[]
behavior: CpuHealBehavior
preferSlots?: boolean
}) {
let livingCount = 0
let healthRatioTotal = 0
let lowest: PartyMember | null = null
let tank: PartyMember | null = null
let cleanseTarget: PartyMember | null = null
let renewTarget: PartyMember | null = null
let shieldTarget: PartyMember | null = null
let woundedCount = 0
for (const member of party) {
if (member.health <= 0) continue
livingCount += 1
const ratio = member.health / effectiveMaxHealth(member)
healthRatioTotal += ratio
if (!lowest || ratio < lowest.health / effectiveMaxHealth(lowest)) lowest = member
if (!tank && member.role === 'Tank') tank = member
if (!cleanseTarget && (member.debuff || (member.poisonStacks ?? 0) > 0)) cleanseTarget = member
if (!renewTarget && member.hotTicks <= 1 && ratio < behavior.hotThreshold) renewTarget = member
if (!shieldTarget && member.role === 'Tank' && member.shield <= 5 && ratio < behavior.shieldThreshold) {
shieldTarget = member
}
if (ratio < behavior.directHealThreshold) woundedCount += 1
}
if (!lowest || livingCount === 0) return []
const averageHealth = healthRatioTotal / livingCount
const spellByKind = (kind: Spell['kind']) => spells.find((candidate) => candidate.kind === kind)
const spellBySlot = (slot: string) => spells.find((candidate) => candidate.key === slot)
const direct = preferSlots ? spellBySlot('1') : spellByKind('direct')
const hot = preferSlots ? spellBySlot('2') : spellByKind('hot')
const group = preferSlots ? spellBySlot('3') : spellByKind('group')
const shield = preferSlots ? spellBySlot('4') : spellByKind('shield')
const cleanse = preferSlots ? spellBySlot('5') : spellByKind('cleanse')
const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [
{ spell: cleanseTarget ? cleanse : undefined, targetId: cleanseTarget?.id ?? null },
{ spell: averageHealth < behavior.groupHealThreshold ? group : undefined, targetId: lowest.id },
{ spell: shieldTarget ? shield : undefined, targetId: shieldTarget?.id ?? null },
{ spell: woundedCount > 0 ? direct : undefined, targetId: lowest.id },
{ spell: renewTarget ? hot : undefined, targetId: renewTarget?.id ?? null },
{ spell: tank ? direct : undefined, targetId: tank?.id ?? null },
]
return ordered
.filter((action): action is { spell: Spell; targetId: string } => Boolean(action.spell && action.targetId))
}
export function chooseCpuHealAction(options: Parameters<typeof chooseCpuHealActions>[0]) {
return chooseCpuHealActions(options)[0] ?? null
}
export function runCpuHealTurn<TSide>({
side,
elapsedTicks,
spells,
behavior,
preferSlots = false,
applySpell,
}: {
side: TSide & { party: PartyMember[] }
elapsedTicks: number
spells: Spell[]
behavior: CpuTurnBehavior
preferSlots?: boolean
applySpell: (side: TSide, spell: Spell, targetId: string) => void
}) {
if (!shouldCpuAct({ elapsedTicks, behavior })) return false
const action = chooseCpuHealAction({
party: side.party,
spells,
behavior,
preferSlots,
})
if (!action) return false
applySpell(side, action.spell, action.targetId)
return true
}
+35
View File
@@ -0,0 +1,35 @@
import type { DualScreenCombatState } from '../dualScreen'
export function buildCombatDualScreenState(state: DualScreenCombatState): DualScreenCombatState {
return state
}
export function buildPvpRoguelikeDualScreenState({
status,
...state
}: Omit<DualScreenCombatState, 'status'> & {
status: DualScreenCombatState['status'] | 'queueing' | 'round-countdown'
}): DualScreenCombatState {
return {
...state,
status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status,
}
}
export function buildStadiumDualScreenState({
status,
...state
}: Omit<DualScreenCombatState, 'status' | 'targetGroup' | 'speedMultiplier'> & {
status: DualScreenCombatState['status'] | 'queueing' | 'round-countdown' | 'shop'
}): DualScreenCombatState {
return {
...state,
status: status === 'queueing' || status === 'round-countdown'
? 'playing'
: status === 'shop'
? 'upgrade-choice'
: status,
targetGroup: 0,
speedMultiplier: 1,
}
}
+71
View File
@@ -0,0 +1,71 @@
import type { DungeonEncounter } from '../profile'
import { chooseRandom } from './rules'
export function encounterThreat(encounter: DungeonEncounter) {
return (
encounter.maxHealth
+ encounter.damage * 18
+ encounter.tankDamage * 10
+ encounter.partyDamage * 18
)
}
export function buildRoguelikeSegment<TMechanic extends string, TExtra extends object = object>({
pool,
stage,
mechanics,
trashCandidateCount,
bossCandidateCount,
healthScale,
damageScale,
partyDamageScale,
idBase,
bossDescription,
bossName,
fallbackBossFirst = false,
extraFields,
}: {
pool: DungeonEncounter[]
stage: number
mechanics: TMechanic[]
trashCandidateCount: (trashCount: number) => number
bossCandidateCount: (bossCount: number) => number
healthScale: number
damageScale: number
partyDamageScale: number
idBase: number
bossDescription: (mechanics: TMechanic[]) => string
bossName?: (encounter: DungeonEncounter) => string
fallbackBossFirst?: boolean
extraFields?: (encounter: DungeonEncounter, isBoss: boolean, mechanics: TMechanic[]) => TExtra
}) {
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
const bossPool = [...pool.filter((encounter) => encounter.isBoss)]
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
const selectedTrash = chooseRandom(trashPool.slice(0, trashCandidateCount(trashPool.length)), 2)
const selectedBoss = chooseRandom(bossPool.slice(0, bossCandidateCount(bossPool.length)), 1)[0]
?? (fallbackBossFirst ? bossPool[0] : trashPool[0])
?? trashPool[0]
?? pool[0]
const selectedMechanics = mechanics
return [...selectedTrash, selectedBoss].map((encounter, index) => {
const isBoss = index === 2
return {
...encounter,
id: idBase + stage * 10 + index,
sequence: (stage - 1) * 3 + index + 1,
isBoss,
encounterType: isBoss ? 'boss' : 'trash',
enemyName: isBoss ? (bossName?.(encounter) ?? `${encounter.enemyName} ${stage}`) : encounter.enemyName,
description: isBoss ? bossDescription(selectedMechanics) : encounter.description,
maxHealth: Math.round(encounter.maxHealth * healthScale),
damage: Math.round(encounter.damage * damageScale),
tankDamage: Math.round(encounter.tankDamage * damageScale),
partyDamage: Math.round(encounter.partyDamage * partyDamageScale),
lootTables: [],
...(extraFields?.(encounter, isBoss, selectedMechanics) ?? {} as TExtra),
}
})
}
+74
View File
@@ -0,0 +1,74 @@
import type { PartyMember } from '../game'
export type PveCombatState = {
party: PartyMember[]
resource: number
enemyHealth: number
cooldowns: Record<string, number>
elapsedTicks: number
castsTowardFree: number
freeCastReady: boolean
}
export function createPveCombatState<TEncounter extends { maxHealth: number }>({
partyTemplate,
maxResource,
encounter,
enemyCount,
}: {
partyTemplate: readonly PartyMember[]
maxResource: number
encounter: TEncounter
enemyCount: number
}): PveCombatState {
return {
party: partyTemplate.map((member) => ({ ...member })),
resource: maxResource,
enemyHealth: encounter.maxHealth * enemyCount,
cooldowns: {},
elapsedTicks: 0,
castsTowardFree: 0,
freeCastReady: false,
}
}
export function createPveRoguelikeRunStart<TEncounter extends { maxHealth: number }>({
partyTemplate,
maxResource,
encounters,
initialEncounterIndex,
enemyCount,
}: {
partyTemplate: readonly PartyMember[]
maxResource: number
encounters: readonly TEncounter[]
initialEncounterIndex: number
enemyCount: number
}) {
return {
combatState: createPveCombatState({
partyTemplate,
maxResource,
encounter: encounters[initialEncounterIndex],
enemyCount,
}),
defaults: {
roguelikeStage: 1,
selectedId: partyTemplate[0].id,
encounterIndex: initialEncounterIndex,
status: 'playing' as const,
paused: false,
targetGroup: 0 as const,
reward: null,
rewardError: '',
lootRolls: [],
showEndLog: false,
floatingTexts: [],
roguelikeUpgrades: [],
upgradeChoices: [],
marathonBossesDefeated: 0,
resourceSpent: 0,
log: { text: 'A new run begins.', tone: 'system' as const },
},
}
}
+153
View File
@@ -0,0 +1,153 @@
export type PvpRoguelikeStatus = 'queueing' | 'round-countdown' | 'playing' | 'upgrade-choice' | 'won' | 'lost'
export type PvpRoguelikeLogTone = 'system' | 'heal' | 'danger' | 'loot'
export type PvpRoguelikeCombatOutcome =
| {
type: 'none'
}
| {
type: 'lost'
status: 'lost'
log: { text: string; tone: PvpRoguelikeLogTone }
}
| {
type: 'won'
status: 'won'
markOpponentDefeated: boolean
log: { text: string; tone: PvpRoguelikeLogTone }
}
| {
type: 'upgrade-choice'
status: 'upgrade-choice'
log: { text: string; tone: PvpRoguelikeLogTone }
}
export function resolvePvpRoguelikeCombatOutcome({
playerAlive,
opponentAlive,
playerCleared,
encounterIsBoss,
encounterName,
opponentDefeated,
liveMatchActive,
opponentLabel,
}: {
playerAlive: boolean
opponentAlive: boolean
playerCleared: boolean
encounterIsBoss: boolean
encounterName: string
opponentDefeated: boolean
liveMatchActive: boolean
opponentLabel: string
}): PvpRoguelikeCombatOutcome {
if (!playerAlive) {
return {
type: 'lost',
status: 'lost',
log: { text: 'Your party fell first.', tone: 'danger' },
}
}
if (!liveMatchActive && !opponentAlive && !opponentDefeated) {
return {
type: 'won',
status: 'won',
markOpponentDefeated: true,
log: { text: `${opponentLabel} fell. Match complete.`, tone: 'loot' },
}
}
if (!playerCleared) return { type: 'none' }
if (encounterIsBoss && opponentDefeated) {
return {
type: 'won',
status: 'won',
markOpponentDefeated: false,
log: { text: `${opponentLabel} defeated. Match complete.`, tone: 'loot' },
}
}
return {
type: 'upgrade-choice',
status: 'upgrade-choice',
log: { text: `${encounterName} cleared. Choose your next edge.`, tone: 'loot' },
}
}
export function resolvePvpRoguelikeLiveSnapshot({
currentStatus,
opponentStatus,
opponentAlive,
alreadyLoggedOpponentDone,
opponentName,
}: {
currentStatus: PvpRoguelikeStatus
opponentStatus?: string
opponentAlive?: boolean
alreadyLoggedOpponentDone: boolean
opponentName: string
}) {
if (currentStatus === 'won' || currentStatus === 'lost') return { type: 'none' as const }
if ((opponentStatus === 'lost' || opponentAlive === false) && !alreadyLoggedOpponentDone) {
return {
type: 'won' as const,
status: 'won' as const,
markOpponentDefeated: true,
log: { text: `${opponentName} fell. Match complete.`, tone: 'loot' as const },
}
}
if (opponentStatus === 'won') {
return {
type: 'lost' as const,
status: 'lost' as const,
log: { text: `${opponentName} finished first.`, tone: 'danger' as const },
}
}
return { type: 'none' as const }
}
export function resolvePvpRoguelikeUpgradeCompletion({
clearedBoss,
opponentDefeated,
opponentLabel,
}: {
clearedBoss: boolean
opponentDefeated: boolean
opponentLabel: string
}) {
if (!clearedBoss || !opponentDefeated) return { type: 'none' as const }
return {
type: 'won' as const,
status: 'won' as const,
log: { text: `${opponentLabel} defeated. Match complete.`, tone: 'loot' as const },
}
}
export function nextPvpRoguelikeStage<TEncounter>({
clearedBoss,
stage,
encounterIndex,
encounters,
nextSegment,
}: {
clearedBoss: boolean
stage: number
encounterIndex: number
encounters: readonly TEncounter[]
nextSegment: readonly TEncounter[]
}) {
const nextStage = clearedBoss ? stage + 1 : stage
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
if (!nextEncounter) {
return {
type: 'complete' as const,
status: 'won' as const,
log: { text: 'No further encounters remain.', tone: 'loot' as const },
}
}
return {
type: 'next' as const,
nextStage,
nextEncounter,
nextEncounterIndex: encounterIndex + 1,
appendSegment: clearedBoss,
}
}
+140
View File
@@ -0,0 +1,140 @@
import type { PartyMember } from '../game'
import type { PvpMatchSnapshot, PvpMatchSide } from '../pvpRoguelike'
export type PvpRoguelikeSetupSide<TBuff extends string, TDebuff extends string> = {
party: PartyMember[]
resource: number
cooldowns: Record<string, number>
enemyHealth: number
buffs: TBuff[]
debuffs: TDebuff[]
castsTowardFree: number
freeCastReady: boolean
}
export type PvpRoguelikeLiveMatchSetup = {
id: string
side: PvpMatchSide
opponentSide: PvpMatchSide
opponentName: string
opponentClassName: string
}
export function createPvpRoguelikeStarterSide<TBuff extends string, TDebuff extends string>(
partyTemplate: readonly PartyMember[],
maxResource: number,
): PvpRoguelikeSetupSide<TBuff, TDebuff> {
return {
party: partyTemplate.map((member) => ({ ...member })),
resource: maxResource,
cooldowns: {},
enemyHealth: 0,
buffs: [],
debuffs: [],
castsTowardFree: 0,
freeCastReady: false,
}
}
export function createPvpRoguelikeMatchStart<
TBuff extends string,
TDebuff extends string,
TEncounter extends { maxHealth: number },
>({
startStage,
encounterPool,
buildSegment,
partyTemplate,
opponentPartyTemplate,
maxResource,
upgradeChoiceSeconds,
}: {
startStage: number
encounterPool: readonly TEncounter[]
buildSegment: (pool: readonly TEncounter[], stage: number) => TEncounter[]
partyTemplate: readonly PartyMember[]
opponentPartyTemplate: readonly PartyMember[]
maxResource: number
upgradeChoiceSeconds: number
}) {
const firstSegment = buildSegment(encounterPool, startStage)
const firstEncounter = firstSegment[0]
const playerSide = createPvpRoguelikeStarterSide<TBuff, TDebuff>(partyTemplate, maxResource)
const opponentSide = createPvpRoguelikeStarterSide<TBuff, TDebuff>(opponentPartyTemplate, maxResource)
playerSide.enemyHealth = firstEncounter.maxHealth
opponentSide.enemyHealth = firstEncounter.maxHealth
return {
startStage,
firstSegment,
firstEncounter,
playerSide,
opponentSide,
defaults: {
encounterIndex: 0,
elapsedTicks: 0,
encountersCleared: 0,
paused: false,
targetGroup: 0 as const,
upgradeTimeLeft: upgradeChoiceSeconds,
liveUpgradePending: false,
rewardError: '',
showEndLog: false,
},
}
}
export function createPvpRoguelikeLiveMatchStart<
TBuff extends string,
TDebuff extends string,
TEncounter extends { maxHealth: number },
>({
match,
side,
encounterPool,
buildSegment,
partyTemplate,
opponentPartyTemplate,
maxResource,
upgradeChoiceSeconds,
message,
}: {
match: PvpMatchSnapshot<PvpRoguelikeSetupSide<TBuff, TDebuff>>
side: PvpMatchSide
encounterPool: readonly TEncounter[]
buildSegment: (pool: readonly TEncounter[], stage: number) => TEncounter[]
partyTemplate: readonly PartyMember[]
opponentPartyTemplate: readonly PartyMember[]
maxResource: number
upgradeChoiceSeconds: number
message?: string
}) {
const opponentSideId: PvpMatchSide = side === 'a' ? 'b' : 'a'
const opponent = match.players[opponentSideId]
const opponentTemplate = opponentPartyTemplate.map((member) => ({
...member,
name: member.id === 'mira' ? opponent.characterName : member.name,
}))
const setup = createPvpRoguelikeMatchStart<TBuff, TDebuff, TEncounter>({
startStage: match.startStage,
encounterPool,
buildSegment,
partyTemplate,
opponentPartyTemplate: opponentTemplate,
maxResource,
upgradeChoiceSeconds,
})
const liveMatch: PvpRoguelikeLiveMatchSetup = {
id: match.id,
side,
opponentSide: opponentSideId,
opponentName: opponent.characterName,
opponentClassName: opponent.className,
}
return {
...setup,
liveMatch,
logText: message ?? `${opponent.characterName} found. Stage ${match.startStage} begins.`,
}
}
+77
View File
@@ -0,0 +1,77 @@
import type { PartyMember } from '../game'
export type PvpRoguelikeUpgradeSide<TBuff extends string, TDebuff extends string> = {
party: PartyMember[]
resource: number
cooldowns: Record<string, number>
enemyHealth: number
buffs: TBuff[]
debuffs: TDebuff[]
}
function removeRandomItem<T>(items: readonly T[], random = Math.random) {
if (items.length === 0) return [...items]
const removedIndex = Math.floor(random() * items.length)
return items.filter((_, index) => index !== removedIndex)
}
export function recoverPartyForNextPvpRoguelikeEncounter(party: readonly PartyMember[]) {
return party.map((member) => ({
...member,
health: member.maxHealth,
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
}))
}
export function applyPvpRoguelikeUpgradeChoice<
TBuff extends string,
TDebuff extends string,
TSide extends PvpRoguelikeUpgradeSide<TBuff, TDebuff>,
>({
side,
buffId,
incomingDebuffId,
reviveBuffId,
random,
}: {
side: TSide
buffId: TBuff
incomingDebuffId?: TDebuff
reviveBuffId: TBuff
random?: () => number
}) {
const reviveSelected = buffId === reviveBuffId
const nextBuffs = reviveSelected ? side.buffs : [...side.buffs, buffId]
const nextDebuffs = incomingDebuffId ? [...side.debuffs, incomingDebuffId] : side.debuffs
return {
...side,
buffs: nextBuffs,
debuffs: reviveSelected ? removeRandomItem(nextDebuffs, random) : nextDebuffs,
}
}
export function preparePvpRoguelikeNextEncounter<
TBuff extends string,
TDebuff extends string,
TSide extends PvpRoguelikeUpgradeSide<TBuff, TDebuff>,
>({
side,
maxResource,
enemyMaxHealth,
}: {
side: TSide
maxResource: number
enemyMaxHealth: number
}) {
return {
...side,
party: recoverPartyForNextPvpRoguelikeEncounter(side.party),
resource: maxResource,
cooldowns: {},
enemyHealth: enemyMaxHealth,
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { PartyMember, Spell } from '../game'
import { applyCastStateUpdate, type CastStateFields } from './combatStateTransitions'
import { canCastSpell } from './spellCasting'
import { applySpellEffectProfile, type SpellEffectProfile, type SpellTargetPlan } from './spellEffects'
export function applyPvpSpellCast<TState extends CastStateFields<PartyMember[]>>({
current,
spell,
targetId,
resourceCost,
targetPlan,
profile,
setCurrent,
emitFloatingHeal,
cooldownMultiplier = 1,
cooldowns,
freeCast,
}: {
current: TState
spell: Spell
targetId: string
resourceCost: number
targetPlan: SpellTargetPlan
profile: SpellEffectProfile
setCurrent: (next: TState) => void
emitFloatingHeal: (memberId: string, value: number) => void
cooldownMultiplier?: number
cooldowns?: Record<string, number>
freeCast?: {
enabled: boolean
wasReady: boolean
}
}) {
if (!canCastSpell(spell, current.resource, current.cooldowns, resourceCost)) return false
const target = current.party.find((member) => member.id === targetId && member.health > 0)
if (!target) return false
const { party: nextParty, floatingHeals } = applySpellEffectProfile({
party: current.party,
spell,
targetId,
plan: targetPlan,
profile,
})
floatingHeals.forEach((event) => emitFloatingHeal(event.memberId, event.value))
const nextState = applyCastStateUpdate({
current,
party: nextParty,
cooldowns,
spell,
resourceCost,
cooldownMultiplier,
freeCast,
})
setCurrent(nextState)
return true
}
+70
View File
@@ -0,0 +1,70 @@
import type { DungeonReward } from '../profile'
export type RewardSummaryBase = {
experienceGained: number
previousLevel: number | null
newLevel: number | null
levelsGained: number
talentPointsGained: number
unlockedAbilities: DungeonReward['unlockedAbilities']
}
export type PvpRunRewardSummary = RewardSummaryBase & {
bossesKilled: number
loot: Array<NonNullable<DungeonReward['bonusItem']>>
}
export function createEmptyRewardSummary(): RewardSummaryBase {
return {
experienceGained: 0,
previousLevel: null,
newLevel: null,
levelsGained: 0,
talentPointsGained: 0,
unlockedAbilities: [],
}
}
export function createEmptyPvpRunSummary(): PvpRunRewardSummary {
return {
bossesKilled: 0,
...createEmptyRewardSummary(),
loot: [],
}
}
export function mergeUnlockedAbilities(
current: DungeonReward['unlockedAbilities'],
next: DungeonReward['unlockedAbilities'],
) {
const unlockedById = new Map(current.map((ability) => [ability.id, ability]))
next.forEach((ability) => unlockedById.set(ability.id, ability))
return Array.from(unlockedById.values())
}
export function mergeDungeonRewardSummary<TSummary extends RewardSummaryBase>(
current: TSummary,
reward: DungeonReward,
) {
return {
...current,
experienceGained: current.experienceGained + reward.experienceGained,
previousLevel: current.previousLevel ?? reward.previousLevel,
newLevel: reward.newLevel,
levelsGained: current.levelsGained + reward.levelsGained,
talentPointsGained: current.talentPointsGained + reward.talentPointsGained,
unlockedAbilities: mergeUnlockedAbilities(current.unlockedAbilities, reward.unlockedAbilities),
}
}
export function mergePvpRunRewardSummary(
current: PvpRunRewardSummary,
reward: DungeonReward,
{ bossKilled }: { bossKilled: boolean },
) {
return {
...mergeDungeonRewardSummary(current, reward),
bossesKilled: current.bossesKilled + (bossKilled ? 1 : 0),
loot: reward.bonusItem ? [...current.loot, reward.bonusItem] : current.loot,
}
}
+81
View File
@@ -0,0 +1,81 @@
import type { Spell } from '../game'
import { createStackCounts, summarizeStackCounts } from './stackCounts'
export type UpgradeChoice<T extends string> = {
id: T
name: string
description: string
}
export function slotLabel(slot: string, spells: Spell[], labelMode: 'ability' | 'slot') {
const spell = spells.find((candidate) => candidate.key === slot)
if (labelMode === 'ability' && spell) return spell.name
return `Slot ${slot}`
}
export function buildSelfSlotUpgradeChoices<T extends string>({
slots,
spells,
labelMode,
idPrefix = 'slot',
}: {
slots: readonly string[]
spells: Spell[]
labelMode: 'ability' | 'slot'
idPrefix?: string
}) {
return slots.flatMap((slot) => {
const label = slotLabel(slot, spells, labelMode)
return [
{
id: `${idPrefix}${slot}-extra-target` as T,
name: `${label}: +1 target`,
description: `${label} affects 1 additional ally when possible.`,
},
{
id: `${idPrefix}${slot}-cost-down` as T,
name: `${label}: -25% cost`,
description: `${label} costs 25% less resource.`,
},
{
id: `${idPrefix}${slot}-cooldown-down` as T,
name: `${label}: -25% cooldown`,
description: `${label} recharges 25% faster.`,
},
]
})
}
export function buildOpponentSlotDebuffChoices<T extends string>({
slots,
spells,
labelMode,
}: {
slots: readonly string[]
spells: Spell[]
labelMode: 'ability' | 'slot'
}) {
return slots.flatMap((slot) => {
const label = slotLabel(slot, spells, labelMode)
return [
{
id: `opp-slot${slot}-cost-up` as T,
name: `${label}: +25% cost`,
description: `Opponent ${label.toLowerCase()} costs 25% more resource.`,
},
{
id: `opp-slot${slot}-cooldown-up` as T,
name: `${label}: +25% cooldown`,
description: `Opponent ${label.toLowerCase()} recharges 25% slower.`,
},
]
})
}
export function summarizeChoiceStacks<T extends string>(
items: T[],
catalog: Array<UpgradeChoice<T>>,
emptyLabel = '',
) {
return summarizeStackCounts(createStackCounts(items), catalog, emptyLabel)
}
+132
View File
@@ -0,0 +1,132 @@
import type { PartyMember, Spell } from '../game'
import type { Ability } from '../profile'
import { createStackCounts, stackCount } from './stackCounts'
export const DEFAULT_TICK_MS = 700
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
export function chooseRandom<T>(items: T[], count: number) {
const pool = [...items]
const result: T[] = []
while (pool.length > 0 && result.length < count) {
const index = Math.floor(Math.random() * pool.length)
result.push(pool.splice(index, 1)[0])
}
return result
}
export function effectiveMaxHealth(member: PartyMember) {
return Math.max(
1,
Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)),
)
}
export function healAmount(member: PartyMember, amount: number, multiplier = 1) {
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier)
}
export function healMember(member: PartyMember, amount: number, multiplier = 1) {
return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member))
}
export 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 effectId(prefix: string) {
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`
}
export 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]
}
export function addBounceHeal(member: PartyMember, spell: Spell) {
return [
...(member.bounceHeals ?? []),
{
id: effectId(spell.id),
label: spell.name,
charges: 4,
power: spell.power,
},
]
}
export function tickHotEffects(effects: PartyMember['hotEffects']) {
return (effects ?? [])
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
.filter((effect) => effect.ticks > 0)
}
export function formatEffectTime(ticks: number, tickMs = DEFAULT_TICK_MS) {
const seconds = (ticks * tickMs) / 1000
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
}
export function buffStacks<T extends string>(items: readonly T[], id: T) {
return stackCount(createStackCounts(items), id)
}
export function createLogEntry(nextLogId: { current: number }, text: string, tone: 'system' | 'heal' | 'danger' | 'loot') {
return { id: nextLogId.current++, text, tone }
}
export function toCombatSpell(ability: Ability, key: string, healingPower = 0): Spell {
const kinds: Record<string, Spell['kind']> = {
direct_heal: 'direct',
direct_hot: 'direct',
heal_over_time: 'hot',
party_heal: 'group',
party_hot: 'group',
party_absorb: 'group',
absorb: 'shield',
damage_reduction: 'damage_reduction',
bounce_heal: 'bounce_heal',
cleanse: 'cleanse',
}
return {
id: String(ability.id),
key,
name: ability.name,
description: ability.description,
cost: ability.cost,
cooldown: ability.cooldown,
power: ability.power + healingPower,
glyph: ability.glyph,
kind: kinds[ability.spellType] ?? 'direct',
effectType: ability.spellType,
}
}
export function resetParty(partyTemplate: PartyMember[]) {
return partyTemplate.map((member) => ({
...member,
health: member.maxHealth,
shield: 0,
hotTicks: 0,
hotEffects: undefined,
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
damageReductionTicks: undefined,
bounceHeals: undefined,
}))
}
+79
View File
@@ -0,0 +1,79 @@
import type { Spell } from '../game'
export function cooldownNow() {
return Date.now()
}
export function cooldownRemaining(
cooldowns: Record<string, number>,
spellId: string,
now = cooldownNow(),
) {
return Math.max(0, ((cooldowns[spellId] ?? 0) - now) / 1000)
}
export function hasActiveCooldowns(
cooldowns: Record<string, number>,
now = cooldownNow(),
) {
return Object.values(cooldowns).some((readyAtMs) => readyAtMs > now)
}
export function pruneExpiredCooldowns(
cooldowns: Record<string, number>,
now = cooldownNow(),
) {
let changed = false
const nextCooldowns: Record<string, number> = {}
for (const id in cooldowns) {
if (cooldowns[id] <= now) {
changed = true
continue
}
nextCooldowns[id] = cooldowns[id]
}
return changed ? nextCooldowns : cooldowns
}
export function reduceCooldown(
cooldowns: Record<string, number>,
spellId: string,
seconds: number,
now = cooldownNow(),
) {
const readyAtMs = cooldowns[spellId]
if (!readyAtMs || readyAtMs <= now) return cooldowns
const nextReadyAtMs = readyAtMs - seconds * 1000
const nextCooldowns = { ...cooldowns }
if (nextReadyAtMs <= now) delete nextCooldowns[spellId]
else nextCooldowns[spellId] = nextReadyAtMs
return nextCooldowns
}
export function canCastSpell(
spell: Spell,
resource: number,
cooldowns: Record<string, number>,
resourceCost: number,
now = cooldownNow(),
) {
return resource >= resourceCost && cooldownRemaining(cooldowns, spell.id, now) <= 0
}
export function putSpellOnCooldown(
cooldowns: Record<string, number>,
spell: Spell,
cooldownMultiplier = 1,
now = cooldownNow(),
) {
const cooldownMs = spell.cooldown * cooldownMultiplier * 1000
if (cooldownMs <= 0) {
const nextCooldowns = { ...cooldowns }
delete nextCooldowns[spell.id]
return nextCooldowns
}
return {
...cooldowns,
[spell.id]: now + cooldownMs,
}
}
+363
View File
@@ -0,0 +1,363 @@
import { DEFAULT_GROUP_HEAL_TARGETS, groupHealTargets, type PartyMember, type Spell } from '../game'
import { addBounceHeal, addHotEffect } from './rules'
export type SpellTargetBucket = 'direct' | 'hot' | 'shield' | 'damageReduction'
export type SpellTargetPlan = {
directTargets: Set<string>
hotTargets: Set<string>
shieldTargets: Set<string>
damageReductionTargets: Set<string>
groupTargets: Set<string>
}
export type ExtraTargetMode = Partial<Record<Spell['kind'], SpellTargetBucket>>
function hasBlockedId(blockedIds: Iterable<string>, id: string) {
if (blockedIds instanceof Set) return blockedIds.has(id)
for (const blockedId of blockedIds) {
if (blockedId === id) return true
}
return false
}
export function lowestHealthExtraTarget(party: PartyMember[], blockedIds: Iterable<string>) {
let target: PartyMember | undefined
let targetRatio = Number.POSITIVE_INFINITY
for (const member of party) {
if (member.health <= 0 || hasBlockedId(blockedIds, member.id)) continue
const healthRatio = member.health / member.maxHealth
if (healthRatio < targetRatio) {
target = member
targetRatio = healthRatio
}
}
return target
}
export function addLowestHealthExtraTarget(
plan: SpellTargetPlan,
party: PartyMember[],
bucket: SpellTargetBucket,
) {
const targetSet = bucket === 'direct'
? plan.directTargets
: bucket === 'hot'
? plan.hotTargets
: bucket === 'shield'
? plan.shieldTargets
: plan.damageReductionTargets
const extra = lowestHealthExtraTarget(party, targetSet)
if (extra) targetSet.add(extra.id)
}
export function buildSpellTargetPlan({
party,
spell,
targetId,
extraTargets,
directTarget,
hotTarget,
shieldTarget,
damageReductionTarget,
groupTargetCount = DEFAULT_GROUP_HEAL_TARGETS,
extraTargetMode,
beforeExtraTargetBuckets = [],
}: {
party: PartyMember[]
spell: Spell
targetId: string
extraTargets: number
directTarget: boolean
hotTarget: boolean
shieldTarget: boolean
damageReductionTarget?: boolean
groupTargetCount?: number
extraTargetMode: ExtraTargetMode
beforeExtraTargetBuckets?: SpellTargetBucket[]
}): SpellTargetPlan {
const plan: SpellTargetPlan = {
directTargets: new Set(directTarget ? [targetId] : []),
hotTargets: new Set(hotTarget ? [targetId] : []),
shieldTargets: new Set(shieldTarget ? [targetId] : []),
damageReductionTargets: new Set(damageReductionTarget ? [targetId] : []),
groupTargets: new Set(
spell.kind === 'group'
? groupHealTargets(party, groupTargetCount).map((member) => member.id)
: [],
),
}
beforeExtraTargetBuckets.forEach((bucket) => {
addLowestHealthExtraTarget(plan, party, bucket)
})
const bucket = extraTargetMode[spell.kind] ?? 'direct'
for (let index = 0; index < extraTargets; index += 1) {
if (spell.kind === 'group') break
addLowestHealthExtraTarget(plan, party, bucket)
}
return plan
}
export function clearNegativeEffects(member: PartyMember) {
return {
...member,
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
}
}
export type FloatingHealEvent = {
memberId: string
value: number
}
export type SpellEffectProfile = {
/**
* Profiles keep mode-specific spell math out of screen components. PvE uses
* stacked HoT effect objects; PvP modes mostly use legacy hotTicks. Stadium
* also applies dampening through the power callbacks below.
*/
modeName: string
heal: (member: PartyMember, power: number, multiplier: number) => number
healingMultiplier: (member: PartyMember) => number
power: {
direct: (spell: Spell) => number
cleanse: (spell: Spell) => number
groupHeal: (spell: Spell) => number
groupAbsorb: (spell: Spell) => number
shield: (sourcePower: number, strength?: number) => number
}
hot: {
mode: 'effects' | 'ticks'
defaultTicks: number
groupTicks: number
radianceTicks: number
merge: 'replace' | 'max'
groupMerge?: 'replace' | 'max'
}
effects: {
renewSpell?: Spell | null
shieldSpell?: Spell | null
groupAbsorbOnly: (spell: Spell) => boolean
groupHotOnly: (spell: Spell) => boolean
groupAppliesShield: (spell: Spell) => boolean
groupAppliesHot: (spell: Spell) => boolean
shieldAppliesHot: (spell: Spell) => boolean
hotSpellForDirect: (spell: Spell) => Spell
}
ratios: {
groupShield: number
directShield: number
}
damageReductionTicks: number
floatingHeals: {
group: boolean
direct: boolean
cleanse: boolean
}
bounceHeals: boolean
}
function applyHot(
member: PartyMember,
spell: Spell,
profile: SpellEffectProfile,
ticks = profile.hot.defaultTicks,
merge = profile.hot.merge,
) {
if (profile.hot.mode === 'effects') {
return {
...member,
hotTicks: 0,
hotEffects: addHotEffect(member, spell, ticks),
}
}
return {
...member,
hotTicks: merge === 'max'
? Math.max(member.hotTicks, ticks)
: ticks,
}
}
function recordFloatingHeal(
events: FloatingHealEvent[],
enabled: boolean,
member: PartyMember,
nextHealth: number,
) {
if (enabled && nextHealth > member.health) {
events.push({ memberId: member.id, value: nextHealth - member.health })
}
}
export function applySpellEffectProfile({
party,
spell,
targetId,
plan,
profile,
}: {
party: PartyMember[]
spell: Spell
targetId: string
plan: SpellTargetPlan
profile: SpellEffectProfile
}) {
const floatingHeals: FloatingHealEvent[] = []
const nextParty = party.map((member) => {
if (member.health <= 0) return member
if (spell.kind === 'group') {
if (!plan.groupTargets.has(member.id)) return member
const isGroupAbsorb = profile.effects.groupAbsorbOnly(spell)
if (profile.effects.groupHotOnly(spell)) {
return applyHot(member, spell, profile, profile.hot.groupTicks, profile.hot.groupMerge)
}
const power = isGroupAbsorb
? profile.power.groupAbsorb(spell)
: profile.power.groupHeal(spell)
const nextHealth = isGroupAbsorb
? member.health
: profile.heal(member, power, profile.healingMultiplier(member))
recordFloatingHeal(floatingHeals, profile.floatingHeals.group, member, nextHealth)
let nextMember: PartyMember = {
...member,
health: nextHealth,
shield: profile.effects.groupAppliesShield(spell)
? Math.max(
member.shield,
isGroupAbsorb
? power
: profile.power.shield(
profile.effects.shieldSpell?.power ?? spell.power,
profile.ratios.groupShield,
),
)
: member.shield,
}
if (profile.effects.groupAppliesHot(spell) && profile.effects.renewSpell) {
nextMember = applyHot(nextMember, profile.effects.renewSpell, profile, profile.hot.radianceTicks, profile.hot.groupMerge)
} else if (profile.effects.groupAppliesHot(spell)) {
nextMember = applyHot(nextMember, spell, profile, profile.hot.groupTicks, profile.hot.groupMerge)
}
return nextMember
}
if (
!plan.directTargets.has(member.id)
&& !plan.hotTargets.has(member.id)
&& !plan.shieldTargets.has(member.id)
&& !plan.damageReductionTargets.has(member.id)
&& !(member.id === targetId && spell.kind === 'bounce_heal' && profile.bounceHeals)
) return member
if (spell.kind === 'shield') {
let nextMember: PartyMember = {
...member,
shield: Math.max(member.shield, profile.power.shield(spell.power)),
}
if (plan.hotTargets.has(member.id) || profile.effects.shieldAppliesHot(spell)) {
nextMember = applyHot(nextMember, profile.effects.renewSpell ?? spell, profile)
}
return nextMember
}
if (spell.kind === 'damage_reduction') {
return {
...member,
damageReductionTicks: profile.hot.merge === 'max'
? Math.max(member.damageReductionTicks ?? 0, profile.damageReductionTicks)
: profile.damageReductionTicks,
hotTicks: plan.hotTargets.has(member.id)
? (profile.hot.merge === 'max'
? Math.max(member.hotTicks, profile.hot.defaultTicks)
: profile.hot.defaultTicks)
: member.hotTicks,
}
}
if (spell.kind === 'bounce_heal' && profile.bounceHeals) {
return { ...member, bounceHeals: addBounceHeal(member, spell) }
}
if (spell.kind === 'cleanse') {
const nextHealth = profile.heal(member, profile.power.cleanse(spell), profile.healingMultiplier(member))
recordFloatingHeal(floatingHeals, profile.floatingHeals.cleanse, member, nextHealth)
let nextMember: PartyMember = {
...clearNegativeEffects(member),
health: nextHealth,
shield: plan.shieldTargets.has(member.id)
? Math.max(
member.shield,
profile.power.shield(profile.effects.shieldSpell?.power ?? spell.power),
)
: member.shield,
}
if (plan.hotTargets.has(member.id)) {
nextMember = applyHot(nextMember, profile.effects.renewSpell ?? spell, profile)
}
return nextMember
}
const nextHealth = plan.directTargets.has(member.id)
? profile.heal(member, profile.power.direct(spell), profile.healingMultiplier(member))
: member.health
recordFloatingHeal(floatingHeals, profile.floatingHeals.direct, member, nextHealth)
let nextMember: PartyMember = {
...member,
health: nextHealth,
shield: plan.shieldTargets.has(member.id)
? Math.max(
member.shield,
profile.power.shield(
profile.effects.shieldSpell?.power ?? spell.power,
profile.ratios.directShield,
),
)
: member.shield,
}
if (plan.hotTargets.has(member.id)) {
nextMember = applyHot(nextMember, profile.effects.hotSpellForDirect(spell), profile)
}
return nextMember
})
return { party: nextParty, floatingHeals }
}
export function advanceFreeCastProgress({
enabled,
wasReady,
castsTowardFree,
}: {
enabled: boolean
wasReady: boolean
castsTowardFree: number
}) {
if (!enabled) {
return {
castsTowardFree,
freeCastReady: wasReady,
}
}
if (wasReady) {
return {
castsTowardFree: 0,
freeCastReady: false,
}
}
const nextCasts = castsTowardFree + 1
return {
castsTowardFree: nextCasts >= 5 ? 0 : nextCasts,
freeCastReady: nextCasts >= 5,
}
}
+81
View File
@@ -0,0 +1,81 @@
import type { Spell } from '../game'
import { stackCount, type StackCounts } from './stackCounts'
export type SlotModifier<T extends string> = {
stacks: StackCounts<T>
id: (slot: string) => T
multiplier?: number
}
export type StackModifier<T extends string> = {
stacks: StackCounts<T>
id: T
multiplier?: number
}
function slotKey(spell: Spell) {
return String(spell.key)
}
function slotModifierStacks<T extends string>(spell: Spell, modifier?: SlotModifier<T>) {
return modifier ? stackCount(modifier.stacks, modifier.id(slotKey(spell))) : 0
}
function stackModifierStacks<T extends string>(modifier?: StackModifier<T>) {
return modifier ? stackCount(modifier.stacks, modifier.id) : 0
}
function stackMultiplier<T extends string>(modifier?: StackModifier<T>) {
if (!modifier) return 1
return (modifier.multiplier ?? 1.25) ** stackModifierStacks(modifier)
}
export function spellSlotMultiplier<TPositive extends string, TNegative extends string = never>(
spell: Spell,
positive?: SlotModifier<TPositive>,
negative?: SlotModifier<TNegative>,
) {
const positiveMultiplier = (positive?.multiplier ?? 0.75) ** slotModifierStacks(spell, positive)
const negativeMultiplier = (negative?.multiplier ?? 1.25) ** slotModifierStacks(spell, negative)
return positiveMultiplier * negativeMultiplier
}
export function spellCooldownMultiplier<TPositive extends string, TNegative extends string = never>(
spell: Spell,
cooldownDown?: SlotModifier<TPositive>,
cooldownUp?: SlotModifier<TNegative>,
) {
return spellSlotMultiplier(spell, cooldownDown, cooldownUp)
}
export function spellResourceCost<TPositive extends string, TNegative extends string = never>({
spell,
costDown,
costUp,
freeCastReady = false,
freeCast,
}: {
spell: Spell
costDown?: SlotModifier<TPositive>
costUp?: SlotModifier<TNegative>
freeCastReady?: boolean
freeCast?: StackModifier<TPositive>
}) {
if (freeCastReady && stackModifierStacks(freeCast) > 0) return 0
return Math.ceil(spell.cost * spellSlotMultiplier(spell, costDown, costUp))
}
export function spellExtraTargets<T extends string>(
spell: Spell,
extraTarget: SlotModifier<T>,
) {
return slotModifierStacks(spell, extraTarget)
}
export function spellPowerMultiplier<T extends string>(modifier: StackModifier<T>) {
return stackMultiplier(modifier)
}
export function hasSpellStack<T extends string>(modifier: StackModifier<T>) {
return stackModifierStacks(modifier) > 0
}
+25
View File
@@ -0,0 +1,25 @@
export type StackCounts<T extends string> = ReadonlyMap<T, number>
export function createStackCounts<T extends string>(items: readonly T[]): StackCounts<T> {
const counts = new Map<T, number>()
items.forEach((item) => counts.set(item, (counts.get(item) ?? 0) + 1))
return counts
}
export function stackCount<T extends string>(counts: StackCounts<T>, id: T) {
return counts.get(id) ?? 0
}
export function summarizeStackCounts<T extends string, TChoice extends { id: T; name: string }>(
counts: StackCounts<T>,
catalog: readonly TChoice[],
emptyLabel = '',
) {
const summary = Array.from(counts.entries())
.map(([id, count]) => {
const label = catalog.find((choice) => choice.id === id)?.name ?? id
return count > 1 ? `${label} x${count}` : label
})
.join(', ')
return summary || emptyLabel
}
+81
View File
@@ -0,0 +1,81 @@
export type StadiumRoundOutcome = 'win' | 'loss' | 'tie'
export type StadiumWins = {
player: number
opponent: number
}
export type StadiumRoundStatus = 'playing' | 'shop' | 'won' | 'lost'
export type StadiumExperienceMode =
| 'pvp-stadium-round-win-quarter-level'
| 'pvp-stadium-round-loss-tenth-level'
| 'pvp-stadium-match-half-level'
export const DEFAULT_STADIUM_WIN_ROUNDS = 3
export function stadiumShopPointsForOutcome(outcome: StadiumRoundOutcome, side: 'player' | 'opponent') {
if (side === 'player') return outcome === 'loss' ? 4 : 3
return outcome === 'win' ? 4 : 3
}
export function resolveStadiumRound({
outcome,
roundIndex,
wins,
winRounds = DEFAULT_STADIUM_WIN_ROUNDS,
}: {
outcome: StadiumRoundOutcome
roundIndex: number
wins: StadiumWins
winRounds?: number
}) {
const roundExperienceMode: StadiumExperienceMode = outcome === 'loss'
? 'pvp-stadium-round-loss-tenth-level'
: 'pvp-stadium-round-win-quarter-level'
const logTone = outcome === 'loss' ? 'danger' as const : 'loot' as const
const nextWins = {
player: wins.player + (outcome === 'win' ? 1 : 0),
opponent: wins.opponent + (outcome === 'loss' ? 1 : 0),
}
const status: StadiumRoundStatus = nextWins.player >= winRounds
? 'won'
: nextWins.opponent >= winRounds
? 'lost'
: 'shop'
return {
nextWins,
status,
playerRoundStatus: status,
roundExperience: {
key: `round-${roundIndex}-${outcome}`,
mode: roundExperienceMode,
},
matchExperience: status === 'won'
? { key: 'match-win', mode: 'pvp-stadium-match-half-level' as StadiumExperienceMode }
: null,
log: {
text: outcome === 'win'
? `Round ${roundIndex} won.`
: outcome === 'loss'
? `Round ${roundIndex} lost.`
: `Round ${roundIndex} tied.`,
tone: logTone,
},
}
}
export function chooseStadiumCpuPurchases<TId extends string, TBuff extends { id: TId; cost: number }>(
catalog: readonly TBuff[],
points: number,
random = Math.random,
) {
let remaining = points
const purchases: TId[] = []
while (remaining > 0) {
const affordable = catalog.filter((buff) => buff.cost <= remaining)
if (affordable.length === 0) break
const selected = affordable[Math.floor(random() * affordable.length)]
purchases.push(selected.id)
remaining -= selected.cost
}
return purchases
}
+124
View File
@@ -0,0 +1,124 @@
import type { PartyMember } from '../game'
import type { PvpMatchSnapshot, PvpMatchSide } from '../pvpRoguelike'
import { resetParty } from './rules'
import type { StadiumRoundOutcome, StadiumWins } from './stadiumLifecycle'
export type StadiumSetupSide<TBuff extends string> = {
party: PartyMember[]
resource: number
cooldowns: Record<string, number>
buffs: TBuff[]
castsTowardFree: number
freeCastReady: boolean
survivalSeconds: number
dampeningPercent: number
roundIndex: number
roundWins: number
roundStatus: 'playing' | 'shop' | 'won' | 'lost'
lastRoundOutcome?: StadiumRoundOutcome
shopReady: boolean
}
export type StadiumLiveMatchSetup = {
id: string
side: PvpMatchSide
opponentSide: PvpMatchSide
opponentName: string
opponentClassName: string
}
export function createStadiumStarterSide<TBuff extends string>({
partyTemplate,
maxResource,
roundIndex,
buffs = [],
roundWins = 0,
}: {
partyTemplate: readonly PartyMember[]
maxResource: number
roundIndex: number
buffs?: TBuff[]
roundWins?: number
}): StadiumSetupSide<TBuff> {
return {
party: resetParty([...partyTemplate]),
resource: maxResource,
cooldowns: {},
buffs,
castsTowardFree: 0,
freeCastReady: false,
survivalSeconds: 0,
dampeningPercent: 0,
roundIndex,
roundWins,
roundStatus: 'playing',
shopReady: false,
}
}
export function createStadiumMatchStart<TBuff extends string>({
partyTemplate,
opponentPartyTemplate,
maxResource,
}: {
partyTemplate: readonly PartyMember[]
opponentPartyTemplate: readonly PartyMember[]
maxResource: number
}) {
const roundIndex = 1
return {
playerSide: createStadiumStarterSide<TBuff>({ partyTemplate, maxResource, roundIndex }),
opponentSide: createStadiumStarterSide<TBuff>({ partyTemplate: opponentPartyTemplate, maxResource, roundIndex }),
defaults: {
roundIndex,
roundWins: { player: 0, opponent: 0 } satisfies StadiumWins,
elapsedTicks: 0,
shopPoints: 0,
shopReady: false,
paused: false,
rewardError: '',
showEndLog: false,
},
}
}
export function createStadiumLiveMatchStart<TBuff extends string>({
match,
side,
partyTemplate,
opponentPartyTemplate,
maxResource,
message,
}: {
match: PvpMatchSnapshot<StadiumSetupSide<TBuff>>
side: PvpMatchSide
partyTemplate: readonly PartyMember[]
opponentPartyTemplate: readonly PartyMember[]
maxResource: number
message?: string
}) {
const opponentSideId: PvpMatchSide = side === 'a' ? 'b' : 'a'
const opponent = match.players[opponentSideId]
const opponentTemplate = opponentPartyTemplate.map((member) => ({
...member,
name: member.id === 'mira' ? opponent.characterName : member.name,
}))
const setup = createStadiumMatchStart<TBuff>({
partyTemplate,
opponentPartyTemplate: opponentTemplate,
maxResource,
})
const liveMatch: StadiumLiveMatchSetup = {
id: match.id,
side,
opponentSide: opponentSideId,
opponentName: opponent.characterName,
opponentClassName: opponent.className,
}
return {
...setup,
liveMatch,
logText: message ?? `${opponent.characterName} found. Stadium begins.`,
}
}
+98
View File
@@ -0,0 +1,98 @@
import type { PartyMember } from '../game'
export type NavigateAction = 'navigateLeft' | 'navigateRight' | 'navigateUp' | 'navigateDown'
type TargetOptions = {
livingOnly?: boolean
}
function canTarget(member: PartyMember, options: TargetOptions) {
return !options.livingOnly || member.health > 0
}
export function selectRelativePartyTarget(
party: PartyMember[],
selectedId: string,
direction: -1 | 1,
options: TargetOptions = {},
) {
const candidates = party.filter((member) => canTarget(member, options))
if (candidates.length === 0) return null
const currentIndex = candidates.findIndex((member) => member.id === selectedId)
const nextIndex = currentIndex < 0
? 0
: (currentIndex + direction + candidates.length) % candidates.length
return candidates[nextIndex]?.id ?? null
}
export function selectDirectionalPartyTarget(
party: PartyMember[],
selectedId: string,
action: NavigateAction,
columns: number,
options: TargetOptions = {},
) {
const currentIndex = party.findIndex((member) => member.id === selectedId)
if (currentIndex < 0) {
return party.find((member) => canTarget(member, options))?.id ?? null
}
const currentRow = Math.floor(currentIndex / columns)
const currentColumn = currentIndex % columns
const horizontal = action === 'navigateLeft' || action === 'navigateRight'
const candidate = party
.map((member, index) => ({
member,
index,
row: Math.floor(index / columns),
column: index % columns,
}))
.filter(({ member, index, row, column }) => {
if (!canTarget(member, options) || index === currentIndex) return false
if (action === 'navigateLeft') return row === currentRow && column < currentColumn
if (action === 'navigateRight') return row === currentRow && column > currentColumn
if (action === 'navigateUp') return row < currentRow
return row > currentRow
})
.sort((left, right) => {
const leftPrimary = horizontal
? Math.abs(left.column - currentColumn)
: Math.abs(left.row - currentRow)
const rightPrimary = horizontal
? Math.abs(right.column - currentColumn)
: Math.abs(right.row - currentRow)
const leftSecondary = horizontal ? 0 : Math.abs(left.column - currentColumn)
const rightSecondary = horizontal ? 0 : Math.abs(right.column - currentColumn)
return leftPrimary - rightPrimary || leftSecondary - rightSecondary
})[0]
return candidate?.member.id ?? null
}
export function selectDirectPartyTarget(
party: PartyMember[],
slot: number,
options: TargetOptions & {
targetGroup?: number
groupSize?: number
} = {},
) {
const groupSize = options.groupSize ?? 6
const index = slot + (options.targetGroup ?? 0) * groupSize
const member = party[index]
return member && canTarget(member, options) ? member.id : null
}
export function nextTargetGroupSelection(
party: PartyMember[],
selectedId: string,
currentGroup: number,
groupSize = 6,
) {
const groupCount = Math.max(1, Math.ceil(party.length / groupSize))
const nextGroup = (currentGroup + 1) % groupCount
const selectedIndex = party.findIndex((member) => member.id === selectedId)
const slot = selectedIndex < 0 ? 0 : selectedIndex % groupSize
return {
group: nextGroup,
selectedId: party[slot + nextGroup * groupSize]?.id ?? null,
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,4 +1,4 @@
import type { CSSProperties } from 'react' import { memo, type CSSProperties } from 'react'
import { import {
bindingLabel, bindingLabel,
compactBindingLabel, compactBindingLabel,
@@ -31,7 +31,7 @@ function faceButtonFor(binding: string, iconStyle: ControllerIconStyle) {
return FACE_BUTTONS[iconStyle][Number(binding.slice(6))] ?? null return FACE_BUTTONS[iconStyle][Number(binding.slice(6))] ?? null
} }
function FaceIcon({ const FaceIcon = memo(function FaceIcon({
color, color,
iconStyle, iconStyle,
label, label,
@@ -52,9 +52,9 @@ function FaceIcon({
{label} {label}
</span> </span>
) )
} })
export function ControllerBindingLabel({ export const ControllerBindingLabel = memo(function ControllerBindingLabel({
binding, binding,
compact = false, compact = false,
iconStyle, iconStyle,
@@ -78,9 +78,9 @@ export function ControllerBindingLabel({
} }
return <>{compact ? compactBindingLabel(binding, iconStyle) : title}</> return <>{compact ? compactBindingLabel(binding, iconStyle) : title}</>
} })
export function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) { export const ControllerStylePreview = memo(function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) {
return ( return (
<span className="controller-style-preview" aria-hidden="true"> <span className="controller-style-preview" aria-hidden="true">
{[0, 1, 2, 3].map((button) => { {[0, 1, 2, 3].map((button) => {
@@ -99,4 +99,4 @@ export function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIco
})} })}
</span> </span>
) )
} })
+263 -20
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { import {
saveProfile, saveProfile,
type CharacterProfile, type CharacterProfile,
type GameClass, type GameClass,
} from '../profile' } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen' import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { useGameAction, type InputAction } from '../input'
import { EquipmentScreen } from './EquipmentScreen' import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen' import { TalentScreen } from './TalentScreen'
@@ -14,20 +15,121 @@ type Props = {
onSaved: (profile: CharacterProfile) => void onSaved: (profile: CharacterProfile) => void
} }
type CustomizeTab = 'equipment' | 'crafting' | 'talents' | 'class'
type CustomizeNavEntry =
| { kind: 'back'; key: string; row: number; column: number }
| { kind: 'tab'; key: string; row: number; column: number; tab: CustomizeTab }
| { kind: 'class'; key: string; row: number; column: number; classId: number }
| { kind: 'slot'; key: string; row: number; column: number; slotIndex: number }
| { kind: 'clear'; key: string; row: number; column: number }
| { kind: 'abilityPagePrev'; key: string; row: number; column: number }
| { kind: 'abilityPageNext'; key: string; row: number; column: number }
| { kind: 'ability'; key: string; row: number; column: number; abilityId: number }
| { kind: 'save'; key: string; row: number; column: number }
const CUSTOMIZE_TABS: Array<{ key: CustomizeTab; label: string }> = [
{ key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
]
const CLASS_PICKER_COLUMN = 0
const CLASS_CONTENT_COLUMN = 2
const ABILITY_LIBRARY_COLUMNS = 5
const ABILITY_LIBRARY_PAGE_SIZE = 10
export function CustomizeScreen({ profile, onBack, onSaved }: Props) { export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class') const [activeTab, setActiveTab] = useState<CustomizeTab>('class')
const { enabled: dualScreenEnabled } = useDualScreen() 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)
const [selectedNavKey, setSelectedNavKey] = useState('tab:class')
const [abilityPage, setAbilityPage] = useState(0)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const scrollRef = useRef<number>(0) const scrollRef = useRef<number>(0)
const navRefs = useRef<Record<string, HTMLElement | null>>({})
const gameClass = profile.classes.find((candidate) => candidate.id === classId)! const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
const abilityMap = useMemo( const abilityMap = useMemo(
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])), () => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
[gameClass], [gameClass],
) )
const abilityPageCount = Math.max(1, Math.ceil(gameClass.spells.length / ABILITY_LIBRARY_PAGE_SIZE))
const currentAbilityPage = Math.min(abilityPage, abilityPageCount - 1)
const visibleAbilities = useMemo(
() => gameClass.spells.slice(
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE,
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE + ABILITY_LIBRARY_PAGE_SIZE,
),
[currentAbilityPage, gameClass.spells],
)
const classNavActive = activeTab === 'class'
const navEntries = useMemo<CustomizeNavEntry[]>(() => {
const entries: CustomizeNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
...CUSTOMIZE_TABS.map((tab, index) => ({
kind: 'tab' as const,
key: `tab:${tab.key}`,
row: 0,
column: index + 1,
tab: tab.key,
})),
]
if (activeTab !== 'class') return entries
entries.push(
...profile.classes.map((candidate, index) => ({
kind: 'class' as const,
key: `class:${candidate.id}`,
row: index + 1,
column: CLASS_PICKER_COLUMN,
classId: candidate.id,
})),
...slots.map((_, index) => ({
kind: 'slot' as const,
key: `slot:${index}`,
row: 1,
column: CLASS_CONTENT_COLUMN + index,
slotIndex: index,
})),
{ kind: 'clear', key: 'clear-slot', row: 2, column: CLASS_CONTENT_COLUMN + 4 },
...(abilityPageCount > 1 && currentAbilityPage > 0
? [{ kind: 'abilityPagePrev' as const, key: 'ability-page-prev', row: 2, column: CLASS_CONTENT_COLUMN + 2 }]
: []),
...(abilityPageCount > 1 && currentAbilityPage < abilityPageCount - 1
? [{ kind: 'abilityPageNext' as const, key: 'ability-page-next', row: 2, column: CLASS_CONTENT_COLUMN + 3 }]
: []),
...visibleAbilities
.filter((ability) => ability.unlockLevel <= profile.character.level)
.map((ability, index) => ({
kind: 'ability' as const,
key: `ability:${ability.id}`,
row: Math.floor(index / ABILITY_LIBRARY_COLUMNS) + 3,
column: CLASS_CONTENT_COLUMN + (index % ABILITY_LIBRARY_COLUMNS),
abilityId: ability.id,
})),
)
if (!saving) {
entries.push({ kind: 'save', key: 'save', row: 99, column: CLASS_CONTENT_COLUMN + 4 })
}
return entries
}, [abilityPageCount, activeTab, currentAbilityPage, profile.character.level, profile.classes, saving, slots, visibleAbilities])
const activeEntry = navEntries.find((entry) => entry.key === selectedNavKey)
?? navEntries.find((entry) => entry.key === `tab:${activeTab}`)
?? navEntries[0]
function selected(entryKey: string) {
return classNavActive && activeEntry?.key === entryKey
}
function navRef(entryKey: string) {
return (node: HTMLElement | null) => {
navRefs.current[entryKey] = node
}
}
useEffect(() => { useEffect(() => {
window.scrollTo(0, scrollRef.current) window.scrollTo(0, scrollRef.current)
@@ -45,9 +147,15 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
setClassId(nextClass.id) setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)]) setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
setSelectedSlot(0) setSelectedSlot(0)
setAbilityPage(0)
setMessage('') setMessage('')
} }
function selectTab(tab: CustomizeTab) {
setActiveTab(tab)
setSelectedNavKey(`tab:${tab}`)
}
function equipAbility(abilityId: number) { function equipAbility(abilityId: number) {
if (slots.includes(abilityId)) { if (slots.includes(abilityId)) {
setMessage('That ability is already equipped.') setMessage('That ability is already equipped.')
@@ -65,6 +173,66 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
) )
} }
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0 || !activeEntry) return
const activeIndex = Math.max(0, navEntries.findIndex((entry) => entry.key === activeEntry.key))
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== activeIndex)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < activeEntry.column
if (action === 'navigateRight') return entry.column > activeEntry.column
if (action === 'navigateUp') return entry.row < activeEntry.row
return entry.row > activeEntry.row
})
if (candidates.length === 0) return
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - activeEntry.row) + Math.abs(a.entry.column - activeEntry.column)
const bPrimary = Math.abs(b.entry.row - activeEntry.row) + Math.abs(b.entry.column - activeEntry.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - activeEntry.row)
: Math.abs(a.entry.column - activeEntry.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - activeEntry.row)
: Math.abs(b.entry.column - activeEntry.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
setSelectedNavKey(candidates[0]?.entry.key ?? activeEntry.key)
}
function openEntry(entry: CustomizeNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'class') {
const nextClass = profile.classes.find((candidate) => candidate.id === entry.classId)
if (nextClass) chooseClass(nextClass)
} else if (entry.kind === 'slot') setSelectedSlot(entry.slotIndex)
else if (entry.kind === 'clear') clearSlot()
else if (entry.kind === 'abilityPagePrev') setAbilityPage((page) => Math.max(0, page - 1))
else if (entry.kind === 'abilityPageNext') setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))
else if (entry.kind === 'ability') equipAbility(entry.abilityId)
else if (entry.kind === 'save') void persistChanges()
}
useEffect(() => {
if (!classNavActive) return
navRefs.current[activeEntry?.key ?? '']?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry, classNavActive])
useGameAction((action, device) => {
if (device !== 'controller' || !classNavActive) return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => { const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'class') return null if (activeTab !== 'class') return null
return { return {
@@ -104,28 +272,46 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
} }
return ( return (
<section className="content-screen customize-screen"> <section className="content-screen customize-screen" data-game-nav-active={classNavActive ? 'true' : undefined}>
<div className="screen-heading customize-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>
</div> </div>
<button className="back-button" onClick={onBack} type="button">Back</button> <button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav={classNavActive ? 'skip' : undefined}
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => setSelectedNavKey('back')}
ref={navRef('back')}
type="button"
>
Back
</button>
</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> <button
{([ className={`back-button customize-tab-back ${selected('back') ? 'game-selected' : ''}`}
{ key: 'equipment', label: 'Equipment' }, data-controller-nav={classNavActive ? 'skip' : undefined}
{ key: 'crafting', label: 'Crafting' }, data-game-selected={selected('back') ? 'true' : undefined}
{ key: 'talents', label: 'Talents' }, onClick={onBack}
{ key: 'class', label: 'Class' }, onPointerDown={() => setSelectedNavKey('back')}
] as const).map((tab) => ( type="button"
>
Back
</button>
{CUSTOMIZE_TABS.map((tab) => (
<button <button
aria-selected={activeTab === tab.key} aria-selected={activeTab === tab.key}
className={activeTab === tab.key ? 'active' : ''} className={`${activeTab === tab.key ? 'active' : ''} ${selected(`tab:${tab.key}`) ? 'game-selected' : ''}`}
data-controller-nav={classNavActive ? 'skip' : undefined}
data-game-selected={selected(`tab:${tab.key}`) ? 'true' : undefined}
key={tab.key} key={tab.key}
onClick={() => setActiveTab(tab.key)} onClick={() => selectTab(tab.key)}
onPointerDown={() => setSelectedNavKey(`tab:${tab.key}`)}
ref={navRef(`tab:${tab.key}`)}
role="tab" role="tab"
type="button" type="button"
> >
@@ -168,10 +354,14 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<p className="eyebrow">Healing Class</p> <p className="eyebrow">Healing Class</p>
{profile.classes.map((candidate) => ( {profile.classes.map((candidate) => (
<button <button
className={candidate.id === classId ? 'active' : ''} className={`${candidate.id === classId ? 'active' : ''} ${selected(`class:${candidate.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`class:${candidate.id}`) ? 'true' : undefined}
key={candidate.id} key={candidate.id}
onClick={() => chooseClass(candidate)} onClick={() => chooseClass(candidate)}
style={{ '--class-color': candidate.themeColor } as React.CSSProperties} onPointerDown={() => setSelectedNavKey(`class:${candidate.id}`)}
ref={navRef(`class:${candidate.id}`)}
style={{ '--class-color': candidate.themeColor } as CSSProperties}
type="button" type="button"
> >
<span>{candidate.name[0]}</span> <span>{candidate.name[0]}</span>
@@ -211,9 +401,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const ability = abilityId ? abilityMap.get(abilityId) : undefined const ability = abilityId ? abilityMap.get(abilityId) : undefined
return ( return (
<button <button
className={selectedSlot === index ? 'selected' : ''} className={`${selectedSlot === index ? 'selected' : ''} ${selected(`slot:${index}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`slot:${index}`) ? 'true' : undefined}
key={index} key={index}
onClick={() => setSelectedSlot(index)} onClick={() => setSelectedSlot(index)}
onPointerDown={() => setSelectedNavKey(`slot:${index}`)}
ref={navRef(`slot:${index}`)}
type="button" type="button"
> >
<kbd>{index + 1}</kbd> <kbd>{index + 1}</kbd>
@@ -229,19 +423,64 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<p className="eyebrow">Class Abilities</p> <p className="eyebrow">Class Abilities</p>
<h2>Ability Library</h2> <h2>Ability Library</h2>
</div> </div>
<button className="text-button" onClick={clearSlot} type="button">Clear Selected Slot</button> <div className="ability-library-actions">
{abilityPageCount > 1 && (
<div className="ability-library-pager">
<button
className={`text-button ${selected('ability-page-prev') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('ability-page-prev') ? 'true' : undefined}
disabled={currentAbilityPage === 0}
onClick={() => setAbilityPage((page) => Math.max(0, page - 1))}
onPointerDown={() => setSelectedNavKey('ability-page-prev')}
ref={navRef('ability-page-prev')}
type="button"
>
Prev
</button>
<span>{currentAbilityPage + 1}/{abilityPageCount}</span>
<button
className={`text-button ${selected('ability-page-next') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('ability-page-next') ? 'true' : undefined}
disabled={currentAbilityPage >= abilityPageCount - 1}
onClick={() => setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))}
onPointerDown={() => setSelectedNavKey('ability-page-next')}
ref={navRef('ability-page-next')}
type="button"
>
Next
</button>
</div>
)}
<button
className={`text-button ${selected('clear-slot') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('clear-slot') ? 'true' : undefined}
onClick={clearSlot}
onPointerDown={() => setSelectedNavKey('clear-slot')}
ref={navRef('clear-slot')}
type="button"
>
Clear Selected Slot
</button>
</div>
</div> </div>
<div className="ability-library"> <div className="ability-library">
{gameClass.spells.map((ability) => { {visibleAbilities.map((ability) => {
const locked = ability.unlockLevel > profile.character.level const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id) const equipped = slots.includes(ability.id)
return ( return (
<button <button
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''}`} className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''} ${selected(`ability:${ability.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`ability:${ability.id}`) ? 'true' : undefined}
disabled={locked} disabled={locked}
key={ability.id} key={ability.id}
onClick={() => equipAbility(ability.id)} onClick={() => equipAbility(ability.id)}
onPointerDown={() => setSelectedNavKey(`ability:${ability.id}`)}
ref={navRef(`ability:${ability.id}`)}
type="button" type="button"
> >
<span>{locked ? 'L' : ability.glyph}</span> <span>{locked ? 'L' : ability.glyph}</span>
@@ -258,9 +497,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<div className="save-row"> <div className="save-row">
<span>{message}</span> <span>{message}</span>
<button <button
className="primary-button" className={`primary-button ${selected('save') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('save') ? 'true' : undefined}
disabled={saving} disabled={saving}
onClick={persistChanges} onClick={persistChanges}
onPointerDown={() => setSelectedNavKey('save')}
ref={navRef('save')}
type="button" type="button"
> >
{saving ? 'Saving...' : 'Save Character'} {saving ? 'Saving...' : 'Save Character'}
+132 -125
View File
@@ -29,6 +29,26 @@ const CRAFTING_LIST_PAGE_SIZE = 3
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[]) const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
.filter((slot) => slot !== 'component') .filter((slot) => slot !== 'component')
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25]) 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
@@ -64,6 +84,7 @@ export function EquipmentScreen({
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'>(mode ?? 'equipment') const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
const activeEquipmentTab = mode ?? equipmentTab
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('')
@@ -77,21 +98,11 @@ export function EquipmentScreen({
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 selectedRecipeRequiresUpgrade = selectedRecipe
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
: false
const selectedItemRecipe = selectedItem const selectedItemRecipe = selectedItem
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id) ? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
: undefined : undefined
const upgradeRecipe = selectedItem && selectedItemRecipe const upgradeRecipe = selectedItem && selectedItemRecipe
? profile.craftingRecipes ? 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(
@@ -118,9 +129,10 @@ export function EquipmentScreen({
1, 1,
Math.ceil(visibleInventory.length / EQUIPMENT_LIST_PAGE_SIZE), Math.ceil(visibleInventory.length / EQUIPMENT_LIST_PAGE_SIZE),
) )
const activeInventoryPage = Math.min(inventoryPage, inventoryPageCount - 1)
const inventoryPageItems = visibleInventory.slice( const inventoryPageItems = visibleInventory.slice(
inventoryPage * EQUIPMENT_LIST_PAGE_SIZE, activeInventoryPage * EQUIPMENT_LIST_PAGE_SIZE,
(inventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE, (activeInventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE,
) )
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all') const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
@@ -158,42 +170,28 @@ export function EquipmentScreen({
1, 1,
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE), Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
) )
const activeRecipePage = Math.min(recipePage, recipePageCount - 1)
const recipePageItems = filteredRecipes.slice( const recipePageItems = filteredRecipes.slice(
recipePage * CRAFTING_LIST_PAGE_SIZE, activeRecipePage * CRAFTING_LIST_PAGE_SIZE,
(recipePage + 1) * CRAFTING_LIST_PAGE_SIZE, (activeRecipePage + 1) * CRAFTING_LIST_PAGE_SIZE,
) )
const activeSelectedRecipeId = filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)
? selectedRecipeId
: filteredRecipes[0]?.id ?? null
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === activeSelectedRecipeId)
const selectedRecipeRequiresUpgrade = selectedRecipe
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
: false
useEffect(() => { useEffect(() => {
window.scrollTo(0, scrollRef.current) window.scrollTo(0, scrollRef.current)
}, [profile]) }, [profile])
useEffect(() => { useEffect(() => {
setInventoryPage((current) => Math.min(current, inventoryPageCount - 1)) if (activeEquipmentTab === 'crafting') {
}, [inventoryPageCount])
useEffect(() => {
setRecipePage((current) => Math.min(current, recipePageCount - 1))
}, [recipePageCount])
useEffect(() => {
if (filteredRecipes.length === 0) {
setSelectedRecipeId(null)
return
}
if (!filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)) {
setSelectedRecipeId(filteredRecipes[0].id)
}
}, [filteredRecipes, selectedRecipeId])
useEffect(() => {
if (equipmentTab === 'crafting') {
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {}) loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
} }
}, [equipmentTab]) }, [activeEquipmentTab, onUpdated])
useEffect(() => {
if (mode) setEquipmentTab(mode)
}, [mode])
function saveScroll() { function saveScroll() {
scrollRef.current = window.scrollY scrollRef.current = window.scrollY
@@ -316,7 +314,7 @@ export function EquipmentScreen({
} }
const workshopState = useMemo<DualScreenWorkshopState>(() => { const workshopState = useMemo<DualScreenWorkshopState>(() => {
if (equipmentTab === 'crafting') { if (activeEquipmentTab === 'crafting') {
if (!selectedRecipe) { if (!selectedRecipe) {
return { return {
mode: 'crafting', mode: 'crafting',
@@ -402,7 +400,7 @@ export function EquipmentScreen({
: []), : []),
], ],
} }
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe]) }, [activeEquipmentTab, comparisonItem, selectedItem, selectedRecipe, upgradeRecipe])
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled) useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
@@ -435,14 +433,14 @@ export function EquipmentScreen({
{showModeTabs && ( {showModeTabs && (
<nav className="equipment-tabs"> <nav className="equipment-tabs">
<button <button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`} className={`equipment-tab ${activeEquipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')} onClick={() => setEquipmentTab('equipment')}
type="button" type="button"
> >
Equipment Equipment
</button> </button>
<button <button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`} className={`equipment-tab ${activeEquipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')} onClick={() => setEquipmentTab('crafting')}
type="button" type="button"
> >
@@ -451,7 +449,7 @@ export function EquipmentScreen({
</nav> </nav>
)} )}
{equipmentTab === 'equipment' ? ( {activeEquipmentTab === 'equipment' ? (
<> <>
<section className="item-comparison"> <section className="item-comparison">
{selectedItem ? ( {selectedItem ? (
@@ -563,11 +561,11 @@ export function EquipmentScreen({
</div> </div>
{visibleInventory.length > EQUIPMENT_LIST_PAGE_SIZE && ( {visibleInventory.length > EQUIPMENT_LIST_PAGE_SIZE && (
<ListPager <ListPager
label={`Page ${inventoryPage + 1} / ${inventoryPageCount}`} label={`Page ${activeInventoryPage + 1} / ${inventoryPageCount}`}
onNext={() => setInventoryPage((current) => Math.min(inventoryPageCount - 1, current + 1))} onNext={() => setInventoryPage(Math.min(inventoryPageCount - 1, activeInventoryPage + 1))}
onPrevious={() => setInventoryPage((current) => Math.max(0, current - 1))} onPrevious={() => setInventoryPage(Math.max(0, activeInventoryPage - 1))}
nextDisabled={inventoryPage >= inventoryPageCount - 1} nextDisabled={activeInventoryPage >= inventoryPageCount - 1}
previousDisabled={inventoryPage <= 0} previousDisabled={activeInventoryPage <= 0}
/> />
)} )}
</section> </section>
@@ -582,8 +580,12 @@ export function EquipmentScreen({
/> />
<div className="crafting-layout"> <div className="crafting-layout">
<aside className="crafting-filters"> <aside className="crafting-filters">
<EquipmentHeading
eyebrow="Slots"
title="Gear Slots"
detail={slotFilter === 'all' ? 'All' : SLOT_LABELS[slotFilter]}
/>
<div> <div>
<p className="eyebrow">Slot</p>
<div className="crafting-filter-grid"> <div className="crafting-filter-grid">
<button <button
className={slotFilter === 'all' ? 'active' : ''} className={slotFilter === 'all' ? 'active' : ''}
@@ -643,89 +645,94 @@ export function EquipmentScreen({
</div> </div>
</aside> </aside>
<section className="crafting-list-panel"> <section className="crafting-available-panel">
<EquipmentHeading <section className="crafting-list-panel">
eyebrow="Recipes" <EquipmentHeading
title={slotFilter === 'all' ? 'Available' : SLOT_LABELS[slotFilter]} eyebrow="Available Gear"
detail={`Page ${recipePage + 1}/${recipePageCount}`} title={slotFilter === 'all' ? 'Craftable Gear' : SLOT_LABELS[slotFilter]}
/> detail={`Page ${activeRecipePage + 1}/${recipePageCount}`}
{filteredRecipes.length === 0 ? ( />
<p className="inventory-empty">No recipes match filters.</p> {filteredRecipes.length === 0 ? (
) : ( <p className="inventory-empty">No recipes match filters.</p>
<div className="crafting-list"> ) : (
{recipePageItems.map((recipe) => ( <div className="crafting-list">
<button {recipePageItems.map((recipe) => (
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`} <button
key={recipe.id} className={`${activeSelectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
onClick={() => setSelectedRecipeId(recipe.id)} key={recipe.id}
type="button" onClick={() => setSelectedRecipeId(recipe.id)}
> type="button"
<span>{recipe.item.glyph}</span> >
<div> <span>{recipe.item.glyph}</span>
<strong>{recipe.item.name}</strong> <div>
<small> <strong>{recipe.item.name}</strong>
{SLOT_LABELS[recipe.item.slot]} - Item Level {recipe.item.itemLevel} <small>
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''} {SLOT_LABELS[recipe.item.slot]} - Item Level {recipe.item.itemLevel}
</small> {recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
</div> </small>
<i className={recipe.canCraft ? 'ready' : 'missing'}> </div>
{recipe.canCraft ? 'Ready' : 'Needs materials'} <i className={recipe.canCraft ? 'ready' : 'missing'}>
</i> {recipe.canCraft ? 'Ready' : 'Needs materials'}
</button> </i>
))} </button>
</div> ))}
)} </div>
)}
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && ( {filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
<ListPager <ListPager
label={`Page ${recipePage + 1} / ${recipePageCount}`} label={`Page ${activeRecipePage + 1} / ${recipePageCount}`}
onNext={() => setRecipePage((current) => Math.min(recipePageCount - 1, current + 1))} onNext={() => setRecipePage(Math.min(recipePageCount - 1, activeRecipePage + 1))}
onPrevious={() => setRecipePage((current) => Math.max(0, current - 1))} onPrevious={() => setRecipePage(Math.max(0, activeRecipePage - 1))}
nextDisabled={recipePage >= recipePageCount - 1} nextDisabled={activeRecipePage >= recipePageCount - 1}
previousDisabled={recipePage <= 0} previousDisabled={activeRecipePage <= 0}
/> />
)} )}
<div className="crafting-action-row"> <div className="crafting-action-row">
<button <button
className="primary-button" className="primary-button"
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting} disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
onClick={craftSelected} onClick={craftSelected}
type="button" type="button"
> >
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'} {crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
</button> </button>
</div>
</section>
<section className="crafting-detail-panel">
{selectedRecipe ? (
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
<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">
{selectedRecipe.components.map((component) => (
<div
className={component.owned >= component.quantity ? 'ready' : 'missing'}
key={component.item.id}
>
<span>{component.item.glyph}</span>
<strong>{component.item.name}</strong>
<i>{component.owned}/{component.quantity}</i>
</div>
))}
</div>
</div> </div>
) : ( </section>
<p className="inventory-empty">Select a recipe.</p>
)} <section className="crafting-detail-panel">
{selectedRecipe ? (
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
<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">
{selectedRecipe.components.length === 0 && (
<p className="inventory-empty">No materials configured. Crafting disabled.</p>
)}
{selectedRecipe.components.map((component) => (
<div
className={component.owned >= component.quantity ? 'ready' : 'missing'}
key={component.item.id}
>
<span>{component.item.glyph}</span>
<strong>{component.item.name}</strong>
<i>{component.owned}/{component.quantity}</i>
</div>
))}
</div>
</div>
) : (
<p className="inventory-empty">Select a recipe.</p>
)}
</section>
</section> </section>
</div> </div>
</section> </section>
)} )}
{equipmentTab === 'equipment' && profile.setBonuses.length > 0 && ( {activeEquipmentTab === '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>
@@ -761,11 +768,11 @@ export function EquipmentScreen({
) )
if (embedded) { if (embedded) {
return <div className={`equipment-screen embedded-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div> return <div className={`equipment-screen embedded-screen ${activeEquipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
} }
return ( return (
<section className={`content-screen equipment-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}> <section className={`content-screen equipment-screen ${activeEquipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
{content} {content}
</section> </section>
) )
+408
View File
@@ -0,0 +1,408 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { useGameAction, type InputAction } from '../input'
type HunterProfileScreenProps = {
profile: CharacterProfile
onBack: () => void
}
type BossEntry = {
encounter: DungeonEncounter
dungeonId: number
dungeonName: string
contentType: 'dungeon' | 'raid'
}
type CollectionItem = {
key: string
glyph: string
name: string
chance: string
quantity: number
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
source: string
}
type HunterProfileTab = 'stats' | 'collection'
type HunterProfileNavEntry =
| { kind: 'back'; key: string; row: number; column: number }
| { kind: 'tab'; key: string; row: number; column: number; tab: HunterProfileTab }
| { kind: 'stat'; key: string; row: number; column: number; index: number }
| { kind: 'boss'; key: string; row: number; column: number; bossId: number }
| { kind: 'item'; key: string; row: number; column: number; itemKey: string }
const HUNTER_PROFILE_DROP_COLUMNS = 6
function bossInitials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
}
function dropChanceLabel(chance: number) {
if (chance <= 0) return 'Unavailable'
const denominator = Math.round(1 / chance)
if (denominator >= 10) return `1 in ${denominator}`
return `${Math.round(chance * 100)}%`
}
function purplePetKey(encounterId: number) {
return `purple:${encounterId}`
}
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
const [activeTab, setActiveTab] = useState<HunterProfileTab>('stats')
const [selectedIndex, setSelectedIndex] = useState(1)
const screenRef = useRef<HTMLElement | null>(null)
const { enabled: dualScreenEnabled } = useDualScreen()
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
dungeon.encounters
.filter((encounter) => encounter.isBoss)
.map((encounter) => ({
encounter,
dungeonId: dungeon.id,
dungeonName: dungeon.name,
contentType: dungeon.contentType,
})),
), [profile.dungeons])
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
const bossKills = useMemo(() => profile.hunterStats?.bossKills ?? {}, [profile.hunterStats?.bossKills])
const bossPets = useMemo(() => profile.hunterStats?.bossPets ?? {}, [profile.hunterStats?.bossPets])
const inventoryQuantities = useMemo(
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
[profile.inventory],
)
const totalBossKills = bosses.reduce((total, boss) => total + (bossKills[String(boss.encounter.id)] ?? 0), 0)
const mostKilledBoss = bosses.reduce<BossEntry | null>((best, boss) => {
if (!best) return boss
return (bossKills[String(boss.encounter.id)] ?? 0) > (bossKills[String(best.encounter.id)] ?? 0)
? boss
: best
}, null)
const matchesPlayed = profile.hunterStats?.pvpMatchesPlayed ?? 0
const matchesWon = profile.hunterStats?.pvpMatchesWon ?? 0
const winRate = matchesPlayed > 0 ? Math.round((matchesWon / matchesPlayed) * 100) : 0
const collectionItems = useMemo<CollectionItem[]>(() => {
if (!selectedBoss) return []
return [
{
key: `kills:${selectedBoss.encounter.id}`,
glyph: 'K',
name: 'Boss Kills',
chance: 'Defeats',
quantity: bossKills[String(selectedBoss.encounter.id)] ?? 0,
rarity: 'stat',
source: selectedBoss.encounter.enemyName,
},
...selectedBoss.encounter.lootTables.map((drop) => ({
key: `drop:${drop.difficultyId}:${drop.id}`,
glyph: drop.glyph,
name: drop.name,
chance: dropChanceLabel(drop.dropChance),
quantity: inventoryQuantities.get(drop.id) ?? 0,
rarity: drop.rarity,
source: selectedBoss.encounter.enemyName,
})),
{
key: `pet:${selectedBoss.encounter.id}`,
glyph: '*',
name: `${selectedBoss.encounter.enemyName} Pet`,
chance: '1 in 500',
quantity: bossPets[String(selectedBoss.encounter.id)] ?? 0,
rarity: 'legendary',
source: selectedBoss.encounter.enemyName,
},
{
key: `purple-pet:${selectedBoss.encounter.id}`,
glyph: 'P',
name: `Purple ${selectedBoss.encounter.enemyName} Pet`,
chance: '1 in 500',
quantity: bossPets[purplePetKey(selectedBoss.encounter.id)] ?? 0,
rarity: 'epic',
source: `${selectedBoss.encounter.enemyName} Roguelike`,
},
]
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
const statTiles = useMemo(() => [
{
key: 'stat:total-kills',
label: 'Total Boss Kills',
value: totalBossKills,
},
{
key: 'stat:most-killed',
label: 'Most Killed Boss',
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None',
detail: totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills',
},
{
key: 'stat:pvp-matches',
label: 'PvP Matches',
value: matchesPlayed,
},
{
key: 'stat:pvp-wins',
label: 'PvP Wins',
value: matchesWon,
},
{
key: 'stat:pvp-win-rate',
label: 'PvP Win Rate',
value: `${winRate}%`,
},
], [bossKills, matchesPlayed, matchesWon, mostKilledBoss, totalBossKills, winRate])
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
const navEntries = useMemo<HunterProfileNavEntry[]>(() => {
const entries: HunterProfileNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
{ kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' },
{ kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' },
]
if (activeTab === 'stats') {
entries.push(...statTiles.map((tile, index) => ({
kind: 'stat' as const,
key: tile.key,
row: 1,
column: index,
index,
})))
} else {
entries.push(
...bosses.map((boss, index) => ({
kind: 'boss' as const,
key: `boss:${boss.encounter.id}`,
row: index + 1,
column: 0,
bossId: boss.encounter.id,
})),
...collectionItems.map((item, index) => ({
kind: 'item' as const,
key: `item:${item.key}`,
row: Math.floor(index / HUNTER_PROFILE_DROP_COLUMNS) + 1,
column: (index % HUNTER_PROFILE_DROP_COLUMNS) + 1,
itemKey: item.key,
})),
)
}
return entries
}, [activeTab, bosses, collectionItems, statTiles])
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
const activeEntry = navEntries[activeIndex]
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
return {
mode: 'collection',
title: focusedItem.name,
subtitle: selectedBoss.encounter.enemyName,
summary: `Owned x${focusedItem.quantity}`,
items: [
{
glyph: focusedItem.glyph,
title: 'Drop Rate',
meta: focusedItem.chance,
detail: focusedItem.source,
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
},
{
title: 'Boss Kills',
meta: `${bossKills[String(selectedBoss.encounter.id)] ?? 0}`,
detail: selectedBoss.dungeonName,
},
],
}
}, [activeTab, bossKills, focusedItem, selectedBoss])
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
function selected(entryKey: string) {
return activeEntry?.key === entryKey
}
function selectKey(entryKey: string) {
const index = navEntries.findIndex((entry) => entry.key === entryKey)
if (index >= 0) setSelectedIndex(index)
}
function selectTab(tab: HunterProfileTab) {
setActiveTab(tab)
setSelectedIndex(tab === 'stats' ? 1 : 2)
}
function selectBoss(bossId: number) {
setSelectedBossId(bossId)
setFocusedItemKey(null)
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openEntry(entry: HunterProfileNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'boss') selectBoss(entry.bossId)
else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey)
}
useEffect(() => {
if (!activeEntry) return
screenRef.current
?.querySelector<HTMLElement>('[data-game-selected="true"]')
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry])
useGameAction((action, inputDevice) => {
if (inputDevice !== 'controller') return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
return (
<section className="content-screen hunter-profile-screen" data-game-nav-active="true" ref={screenRef}>
<div className="screen-heading hunter-profile-heading">
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
<button
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''} ${selected('tab:stats') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:stats') ? 'true' : undefined}
onClick={() => selectTab('stats')}
onPointerDown={() => selectKey('tab:stats')}
type="button"
role="tab"
aria-selected={activeTab === 'stats'}
>
Stats
</button>
<button
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''} ${selected('tab:collection') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:collection') ? 'true' : undefined}
onClick={() => selectTab('collection')}
onPointerDown={() => selectKey('tab:collection')}
type="button"
role="tab"
aria-selected={activeTab === 'collection'}
>
Collection Log
</button>
</div>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectKey('back')}
type="button"
>
Back
</button>
</div>
{activeTab === 'stats' && (
<div className="hunter-stat-grid">
{statTiles.map((tile) => (
<article
className={`hunter-stat-tile ${selected(tile.key) ? 'game-selected' : ''}`}
data-game-selected={selected(tile.key) ? 'true' : undefined}
key={tile.key}
onPointerDown={() => selectKey(tile.key)}
>
<span>{tile.label}</span>
<strong>{tile.value}</strong>
{tile.detail && <small>{tile.detail}</small>}
</article>
))}
</div>
)}
{activeTab === 'collection' && (
<div className="collection-log-layout">
<div className="collection-boss-list" aria-label="Bosses">
{bosses.map((boss) => {
return (
<button
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''} ${selected(`boss:${boss.encounter.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`boss:${boss.encounter.id}`) ? 'true' : undefined}
key={boss.encounter.id}
onClick={() => selectBoss(boss.encounter.id)}
onPointerDown={() => selectKey(`boss:${boss.encounter.id}`)}
type="button"
>
<span>{bossInitials(boss.encounter.enemyName)}</span>
<strong>{boss.encounter.enemyName}</strong>
</button>
)
})}
</div>
{selectedBoss && (
<article className="collection-boss-detail">
<div className="collection-drop-list">
{collectionItems.map((item) => (
<button
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'} ${selected(`item:${item.key}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`item:${item.key}`) ? 'true' : undefined}
key={item.key}
onClick={() => setFocusedItemKey(item.key)}
onFocus={() => setFocusedItemKey(item.key)}
onPointerDown={() => selectKey(`item:${item.key}`)}
type="button"
>
<span>{item.glyph}</span>
<strong>{item.name}</strong>
<small>{item.chance}</small>
<b>x{item.quantity}</b>
</button>
))}
</div>
</article>
)}
</div>
)}
</section>
)
}
+154
View File
@@ -0,0 +1,154 @@
import { memo, type ElementType, type ReactNode } from 'react'
import type { ControllerIconStyle } from '../input'
import type { PartyMember } from '../game'
import {
effectiveMaxHealth,
formatEffectTime,
memberHotEffects,
} from '../combat/rules'
import { ControllerBindingLabel } from './ControllerIcons'
import { barFillStyle } from './barStyles'
export type FloatingCombatText = {
id: number
memberId: string
value: number
}
type MemberEffectMode = 'full' | 'compact' | 'timed-basic'
const MemberEffects = memo(function MemberEffects({
member,
mode,
}: {
member: PartyMember
mode: MemberEffectMode
}) {
if (mode === 'compact') {
return (
<div className="member-effects">
{memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label}</span>
))}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{member.debuff && <span className="debuff">{member.debuff}</span>}
</div>
)
}
if (mode === 'timed-basic') {
return (
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
{(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
{(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
</div>
)
}
return (
<div className="member-effects">
{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.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
{(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
</div>
)
})
export const PartyMemberFrame = memo(function PartyMemberFrame({
as,
member,
selected = false,
baseClassName = 'party-member',
selectedClassName = 'selected',
deadClassName = 'dead',
effectMode = 'full',
floatingTexts = [],
showTargetMarker = false,
showHeaderHealth = true,
showHealthText = false,
targetBinding,
controllerIconStyle,
onSelect,
children,
}: {
as?: ElementType
member: PartyMember
selected?: boolean
baseClassName?: string
selectedClassName?: string
deadClassName?: string
effectMode?: MemberEffectMode
floatingTexts?: FloatingCombatText[]
showTargetMarker?: boolean
showHeaderHealth?: boolean
showHealthText?: boolean
targetBinding?: string | null
controllerIconStyle?: ControllerIconStyle
onSelect?: (id: string) => void
children?: ReactNode
}) {
const Component = as ?? (onSelect ? 'button' : 'div')
const maxHealth = effectiveMaxHealth(member)
const className = [
baseClassName,
selected ? selectedClassName : '',
member.health <= 0 ? deadClassName : '',
].filter(Boolean).join(' ')
const action = targetBinding ? {
binding: targetBinding,
iconStyle: controllerIconStyle ?? 'playstation',
} : null
return (
<Component
className={className}
data-party-member-id={member.id}
onClick={onSelect ? () => onSelect(member.id) : undefined}
aria-pressed={onSelect ? selected : undefined}
type={Component === 'button' ? 'button' : undefined}
>
{showTargetMarker && (
<span className="target-marker" aria-hidden="true">
<i />
Target
</span>
)}
<div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong>
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
</div>
<div className="bar member-health">
<span style={barFillStyle((member.health / maxHealth) * 100)} />
{member.shield > 0 && <i style={barFillStyle((member.shield / maxHealth) * 100, 'right')} />}
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div>
{action && (
<div className="member-target-key">
<ControllerBindingLabel
binding={action.binding}
iconStyle={action.iconStyle}
/>
</div>
)}
<MemberEffects member={member} mode={effectMode} />
{children}
</Component>
)
})
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More