diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80e4a07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +data/*.db +data/*.db-shm +data/*.db-wal +backups/*.db + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..d9c4e71 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,87 @@ +# Self-Hosting + +The game can run on your own Windows PC and use router port forwarding. Account +registration, sessions, characters, progression, inventory, loot history, and +leaderboards are stored in the SQLite database at `data/game.db`. + +That database file is the persistent storage. It remains after the server or PC +restarts. If the file is deleted or lost, all accounts and save data are lost, +so keep regular backups. + +## Build and run + +```powershell +npm ci +npm run db:init +npm run build +$env:HOST = "127.0.0.1" +$env:PORT = "4173" +$env:TRUST_PROXY = "1" +npm start +``` + +Run `npm run db:init` after pulling schema changes. It creates missing tables and +seed content without erasing existing player data. + +## Internet access + +Do not expose the Node port directly over unencrypted HTTP. Passwords would +travel across the internet without encryption. Put Caddy or another HTTPS +reverse proxy in front of the game: + +```caddyfile +game.example.com { + reverse_proxy 127.0.0.1:4173 +} +``` + +Point a domain or dynamic-DNS name to your public IP, forward router ports 80 +and 443 to the PC running Caddy, and allow those ports through Windows Firewall. +Caddy obtains and renews the HTTPS certificate. + +Keep the game server bound to `127.0.0.1`. Set `TRUST_PROXY=1` only when the +server can be reached solely through your local reverse proxy. This lets account +limits use the visitor's public IP instead of the proxy's address. + +## Account limits + +Registration permits one account per public IP by default. Login and API rate +limits also apply. To allow a shared household to create more accounts, run a +local administrator command: + +```powershell +npm run accounts:ip -- set 203.0.113.42 4 Smith-household +npm run accounts:ip -- list +npm run accounts:ip -- remove 203.0.113.42 +``` + +`set` changes the total number of accounts that address may create. `remove` +returns it to the default one-account limit. These commands must be run on the +host PC and are not exposed through the website. + +An IP account limit reduces casual account spam, but it is not DDoS protection. +Your router and internet connection can still be overwhelmed before requests +reach the game. Keep Windows patched, expose only required ports, and use router +or ISP protections where available. + +## Backups + +Create a consistent live backup with: + +```powershell +npm run db:backup +``` + +Backups are written to `backups/game-.db`. Copy them to another disk +or cloud backup location; backups left only on the game PC will not help if its +drive fails. Test restoration occasionally by stopping the server, preserving +the current `data/game.db`, and placing a backup at that path. + +## Current scaling boundary + +SQLite is a good fit for a small private server running one game process. Do not +run multiple Node server instances against this setup. + +Combat currently runs in the browser. It is suitable for a friend group, but a +modified client could submit false completion metrics. Move combat simulation +and run validation to the server before treating rankings as cheat-resistant. diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..ed4657c --- /dev/null +++ b/admin.html @@ -0,0 +1,13 @@ + + + + + + + Game Admin + + +
+ + + diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..6a7af21 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,54 @@ +apply plugin: 'com.android.application' + +android { + namespace = "com.warren.iwanttoheal" + compileSdk = rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.warren.iwanttoheal" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle new file mode 100644 index 0000000..bbfb44f --- /dev/null +++ b/android/app/capacitor.build.gradle @@ -0,0 +1,19 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..488f8bd --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java b/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java new file mode 100644 index 0000000..1d95ac2 --- /dev/null +++ b/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java @@ -0,0 +1,107 @@ +package com.warren.iwanttoheal; + +import android.content.Intent; +import android.os.Bundle; +import android.view.KeyEvent; +import android.view.View; +import com.getcapacitor.BridgeActivity; + +public abstract class ControllerBridgeActivity extends BridgeActivity { + + public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL"; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + loadIntentUrl(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + loadIntentUrl(); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) enableImmersiveMode(); + } + + private void loadIntentUrl() { + if (bridge == null || getIntent() == null) return; + String initialUrl = getIntent().getStringExtra(EXTRA_INITIAL_URL); + if (initialUrl == null || initialUrl.isEmpty()) return; + bridge.getWebView().post(() -> bridge.getWebView().loadUrl(initialUrl)); + } + + private void enableImmersiveMode() { + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE + ); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + String token = controllerToken(event.getKeyCode()); + if (token == null || bridge == null) return super.dispatchKeyEvent(event); + + if (event.getAction() == KeyEvent.ACTION_DOWN) { + boolean repeat = event.getRepeatCount() > 0; + String script = + "window.dispatchEvent(new CustomEvent('ashen-halls-native-controller'," + + "{detail:{token:'" + token + "',repeat:" + repeat + "}}));"; + bridge.getWebView().post( + () -> bridge.getWebView().evaluateJavascript(script, null) + ); + } + return true; + } + + private String controllerToken(int keyCode) { + switch (keyCode) { + case KeyEvent.KEYCODE_BUTTON_A: + case KeyEvent.KEYCODE_ENTER: + return "Button0"; + case KeyEvent.KEYCODE_BUTTON_B: + case KeyEvent.KEYCODE_BACK: + return "Button1"; + case KeyEvent.KEYCODE_BUTTON_X: + return "Button2"; + case KeyEvent.KEYCODE_BUTTON_Y: + return "Button3"; + case KeyEvent.KEYCODE_BUTTON_L1: + return "Button4"; + case KeyEvent.KEYCODE_BUTTON_R1: + return "Button5"; + case KeyEvent.KEYCODE_BUTTON_L2: + return "Button6"; + case KeyEvent.KEYCODE_BUTTON_R2: + return "Button7"; + case KeyEvent.KEYCODE_BUTTON_SELECT: + return "Button8"; + case KeyEvent.KEYCODE_BUTTON_START: + return "Button9"; + case KeyEvent.KEYCODE_BUTTON_THUMBL: + return "Button10"; + case KeyEvent.KEYCODE_BUTTON_THUMBR: + return "Button11"; + case KeyEvent.KEYCODE_DPAD_UP: + return "Button12"; + case KeyEvent.KEYCODE_DPAD_DOWN: + return "Button13"; + case KeyEvent.KEYCODE_DPAD_LEFT: + return "Button14"; + case KeyEvent.KEYCODE_DPAD_RIGHT: + return "Button15"; + default: + return null; + } + } +} diff --git a/android/app/src/main/java/com/warren/iwanttoheal/DualScreenPlugin.java b/android/app/src/main/java/com/warren/iwanttoheal/DualScreenPlugin.java new file mode 100644 index 0000000..8fd2782 --- /dev/null +++ b/android/app/src/main/java/com/warren/iwanttoheal/DualScreenPlugin.java @@ -0,0 +1,315 @@ +package com.warren.iwanttoheal; + +import android.app.Presentation; +import android.content.Context; +import android.graphics.Color; +import android.hardware.display.DisplayManager; +import android.os.Bundle; +import android.view.Display; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.FrameLayout; +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +@CapacitorPlugin(name = "DualScreen") +public class DualScreenPlugin extends Plugin { + + private TopDisplayPresentation presentation; + + @PluginMethod + public void getDisplays(PluginCall call) { + DisplayManager displayManager = + (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE); + Display currentDisplay = getActivity().getDisplay(); + JSArray displays = new JSArray(); + + for (Display display : displayManager.getDisplays()) { + displays.put(describeDisplay(display, currentDisplay)); + } + + JSObject result = new JSObject(); + result.put("currentDisplayId", currentDisplay == null ? -1 : currentDisplay.getDisplayId()); + result.put("displays", displays); + call.resolve(result); + } + + @PluginMethod + public void openTopDisplay(PluginCall call) { + getActivity().runOnUiThread(() -> { + try { + DisplayLaunchPlan launchPlan = createLaunchPlan(call.getInt("displayId")); + if (launchPlan.targetDisplay == null) { + call.reject("No secondary Android display is available."); + return; + } + + Display currentDisplay = getActivity().getDisplay(); + if ( + currentDisplay != null + && currentDisplay.getDisplayId() == launchPlan.targetDisplay.getDisplayId() + ) { + JSObject result = describeDisplay(currentDisplay, currentDisplay); + result.put("opened", true); + result.put("alreadyOnMainDisplay", true); + call.resolve(result); + return; + } + + String gameUrl = bridge.getLocalUrl(); + String controlsUrl = gameUrl + "/?display=bottom"; + String targetUrl = launchPlan.targetIsTopDisplay ? gameUrl : controlsUrl; + String currentUrl = launchPlan.currentIsTopDisplay ? gameUrl : controlsUrl; + + closePresentation(); + presentation = new TopDisplayPresentation( + getActivity(), + launchPlan.targetDisplay, + targetUrl + ); + presentation.setOnDismissListener(dialog -> { + presentation = null; + notifyListeners("displayDisconnected", new JSObject()); + }); + presentation.show(); + bridge.getWebView().loadUrl(currentUrl); + + JSObject result = describeDisplay(launchPlan.targetDisplay, getActivity().getDisplay()); + result.put("opened", true); + result.put("presentationMode", true); + result.put("targetIsTopDisplay", launchPlan.targetIsTopDisplay); + result.put("currentIsTopDisplay", launchPlan.currentIsTopDisplay); + call.resolve(result); + } catch (Exception exception) { + closePresentation(); + call.reject("Unable to open the upper display.", exception); + } + }); + } + + @PluginMethod + public void closeTopDisplay(PluginCall call) { + getActivity().runOnUiThread(() -> { + closePresentation(); + call.resolve(); + }); + } + + @Override + protected void handleOnDestroy() { + closePresentation(); + } + + private DisplayLaunchPlan createLaunchPlan(Integer requestedDisplayId) { + DisplayManager displayManager = + (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE); + Display currentDisplay = getActivity().getDisplay(); + int currentDisplayId = currentDisplay == null ? -1 : currentDisplay.getDisplayId(); + Display topDisplay = largestDisplay(displayManager.getDisplays()); + boolean currentIsTopDisplay = + topDisplay != null && topDisplay.getDisplayId() == currentDisplayId; + Display targetDisplay = null; + + if (requestedDisplayId != null) { + Display requested = displayManager.getDisplay(requestedDisplayId); + if ( + requested != null + && requested.getDisplayId() != currentDisplayId + && requested.getState() != Display.STATE_OFF + ) { + targetDisplay = requested; + } + } + + if (targetDisplay == null && currentIsTopDisplay) { + targetDisplay = smallestOtherDisplay(displayManager.getDisplays(), currentDisplayId); + } + + if (targetDisplay == null && topDisplay != null && topDisplay.getDisplayId() != currentDisplayId) { + targetDisplay = topDisplay; + } + + if (targetDisplay == null) { + targetDisplay = largestOtherDisplay( + displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION), + currentDisplayId + ); + } + + if (targetDisplay == null) { + targetDisplay = largestOtherDisplay(displayManager.getDisplays(), currentDisplayId); + } + + boolean targetIsTopDisplay = + topDisplay != null && targetDisplay != null + && targetDisplay.getDisplayId() == topDisplay.getDisplayId(); + return new DisplayLaunchPlan(targetDisplay, targetIsTopDisplay, currentIsTopDisplay); + } + + private Display largestDisplay(Display[] displays) { + Display selected = null; + long selectedPixels = -1; + for (Display display : displays) { + if (display.getState() == Display.STATE_OFF) continue; + long pixels = pixelCount(display); + if (pixels > selectedPixels) { + selected = display; + selectedPixels = pixels; + } + } + return selected; + } + + private Display largestOtherDisplay(Display[] displays, int currentDisplayId) { + Display selected = null; + long selectedPixels = -1; + for (Display display : displays) { + if ( + display.getDisplayId() == currentDisplayId + || display.getState() == Display.STATE_OFF + ) { + continue; + } + long pixels = pixelCount(display); + if (pixels > selectedPixels) { + selected = display; + selectedPixels = pixels; + } + } + return selected; + } + + private Display smallestOtherDisplay(Display[] displays, int currentDisplayId) { + Display selected = null; + long selectedPixels = Long.MAX_VALUE; + for (Display display : displays) { + if ( + display.getDisplayId() == currentDisplayId + || display.getState() == Display.STATE_OFF + ) { + continue; + } + long pixels = pixelCount(display); + if (pixels < selectedPixels) { + selected = display; + selectedPixels = pixels; + } + } + return selected; + } + + private long pixelCount(Display display) { + Display.Mode mode = display.getMode(); + return (long) mode.getPhysicalWidth() * mode.getPhysicalHeight(); + } + + private JSObject describeDisplay(Display display, Display currentDisplay) { + Display.Mode mode = display.getMode(); + JSObject result = new JSObject(); + result.put("id", display.getDisplayId()); + result.put("name", display.getName()); + result.put("width", mode.getPhysicalWidth()); + result.put("height", mode.getPhysicalHeight()); + result.put("refreshRate", mode.getRefreshRate()); + result.put( + "isCurrent", + currentDisplay != null && currentDisplay.getDisplayId() == display.getDisplayId() + ); + result.put( + "isPresentation", + (display.getFlags() & Display.FLAG_PRESENTATION) == Display.FLAG_PRESENTATION + ); + return result; + } + + private void closePresentation() { + if (presentation == null) return; + presentation.setOnDismissListener(null); + presentation.dismiss(); + presentation = null; + } + + private final class DisplayLaunchPlan { + + private final Display targetDisplay; + private final boolean targetIsTopDisplay; + private final boolean currentIsTopDisplay; + + DisplayLaunchPlan( + Display targetDisplay, + boolean targetIsTopDisplay, + boolean currentIsTopDisplay + ) { + this.targetDisplay = targetDisplay; + this.targetIsTopDisplay = targetIsTopDisplay; + this.currentIsTopDisplay = currentIsTopDisplay; + } + } + + private final class TopDisplayPresentation extends Presentation { + + private final String initialUrl; + + TopDisplayPresentation(Context context, Display display, String initialUrl) { + super(context, display); + this.initialUrl = initialUrl; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getWindow().setBackgroundDrawableResource(android.R.color.black); + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE + ); + + FrameLayout container = new FrameLayout(getContext()); + container.setBackgroundColor(Color.BLACK); + WebView webView = new WebView(getContext()); + configureWebView(webView); + container.addView( + webView, + new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ); + setContentView(container); + webView.loadUrl(initialUrl); + } + } + + private void configureWebView(WebView webView) { + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setDatabaseEnabled(true); + settings.setMediaPlaybackRequiresUserGesture(false); + settings.setAllowFileAccess(true); + settings.setAllowContentAccess(true); + webView.setBackgroundColor(Color.BLACK); + webView.setWebViewClient(new WebViewClient() { + @Override + public WebResourceResponse shouldInterceptRequest( + WebView view, + WebResourceRequest request + ) { + return bridge.getLocalServer().shouldInterceptRequest(request); + } + }); + } +} diff --git a/android/app/src/main/java/com/warren/iwanttoheal/GameActivity.java b/android/app/src/main/java/com/warren/iwanttoheal/GameActivity.java new file mode 100644 index 0000000..d6263df --- /dev/null +++ b/android/app/src/main/java/com/warren/iwanttoheal/GameActivity.java @@ -0,0 +1,11 @@ +package com.warren.iwanttoheal; + +import android.os.Bundle; + +public class GameActivity extends ControllerBridgeActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + registerPlugin(DualScreenPlugin.class); + super.onCreate(savedInstanceState); + } +} diff --git a/android/app/src/main/java/com/warren/iwanttoheal/MainActivity.java b/android/app/src/main/java/com/warren/iwanttoheal/MainActivity.java new file mode 100644 index 0000000..774494e --- /dev/null +++ b/android/app/src/main/java/com/warren/iwanttoheal/MainActivity.java @@ -0,0 +1,11 @@ +package com.warren.iwanttoheal; + +import android.os.Bundle; + +public class MainActivity extends ControllerBridgeActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + registerPlugin(DualScreenPlugin.class); + super.onCreate(savedInstanceState); + } +} diff --git a/android/app/src/main/res/drawable-land-hdpi/splash.png b/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..e31573b Binary files /dev/null and b/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-mdpi/splash.png b/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xhdpi/splash.png b/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..8077255 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..14c6c8f Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..244ca25 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-hdpi/splash.png b/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..74faaa5 Binary files /dev/null and b/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-mdpi/splash.png b/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..e944f4a Binary files /dev/null and b/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xhdpi/splash.png b/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..564a82f Binary files /dev/null and b/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..bfabe68 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..6929071 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/splash.png b/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/android/app/src/main/res/drawable/splash.png differ diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..c023e50 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2127973 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..b441f37 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..72905b8 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8ed0605 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..9502e47 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..4d1e077 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..df0f158 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..853db04 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..6cdf97c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2960cbb Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8e3093a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..46de6e2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d2ea9ab Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a40d73e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..5c721bf --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + I want to Heal + I want to Heal + com.warren.iwanttoheal + com.warren.iwanttoheal + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..be874e5 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..f8f0e43 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.google.gms:google-services:4.4.4' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle new file mode 100644 index 0000000..9a5fa87 --- /dev/null +++ b/android/capacitor.settings.gradle @@ -0,0 +1,3 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7705927 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/android/variables.gradle b/android/variables.gradle new file mode 100644 index 0000000..b01e46f --- /dev/null +++ b/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 35 + targetSdkVersion = 35 + androidxActivityVersion = '1.10.1' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.16.0' + androidxFragmentVersion = '1.8.9' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' +} diff --git a/capacitor.config.ts b/capacitor.config.ts new file mode 100644 index 0000000..442c490 --- /dev/null +++ b/capacitor.config.ts @@ -0,0 +1,9 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'com.warren.iwanttoheal', + appName: 'I want to Heal', + webDir: 'dist' +}; + +export default config; diff --git a/data/uploads/bosses/bulldrome-1781641624302-2c45f7f6.png b/data/uploads/bosses/bulldrome-1781641624302-2c45f7f6.png new file mode 100644 index 0000000..6ad5822 Binary files /dev/null and b/data/uploads/bosses/bulldrome-1781641624302-2c45f7f6.png differ diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..d6f4a26 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,290 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS locations ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS dungeons ( + id INTEGER PRIMARY KEY, + location_id INTEGER NOT NULL REFERENCES locations(id), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + recommended_level INTEGER NOT NULL DEFAULT 1, + content_type TEXT NOT NULL DEFAULT 'dungeon' CHECK (content_type IN ('dungeon', 'raid')), + party_size INTEGER NOT NULL DEFAULT 5, + completion_item_level INTEGER, + experience_reward INTEGER NOT NULL DEFAULT 100, + description TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS difficulties ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + dropped_item_level INTEGER NOT NULL, + unlock_level INTEGER NOT NULL DEFAULT 1, + health_multiplier REAL NOT NULL DEFAULT 1, + damage_multiplier REAL NOT NULL DEFAULT 1, + experience_multiplier REAL NOT NULL DEFAULT 1, + description TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS dungeon_difficulties ( + dungeon_id INTEGER NOT NULL REFERENCES dungeons(id) ON DELETE CASCADE, + difficulty_id INTEGER NOT NULL REFERENCES difficulties(id), + PRIMARY KEY (dungeon_id, difficulty_id) +); + +CREATE TABLE IF NOT EXISTS encounters ( + id INTEGER PRIMARY KEY, + dungeon_id INTEGER NOT NULL REFERENCES dungeons(id), + sequence INTEGER NOT NULL, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + encounter_type TEXT NOT NULL CHECK (encounter_type IN ('trash', 'boss')), + max_health INTEGER NOT NULL, + base_damage INTEGER NOT NULL, + tank_damage INTEGER NOT NULL, + party_damage INTEGER NOT NULL, + description TEXT NOT NULL, + image_url TEXT NOT NULL DEFAULT '/boss-placeholder.svg', + UNIQUE (dungeon_id, sequence) +); + +CREATE TABLE IF NOT EXISTS mechanics ( + id INTEGER PRIMARY KEY, + encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE, + name TEXT NOT NULL, + mechanic_type TEXT NOT NULL, + interval_seconds REAL, + power INTEGER NOT NULL DEFAULT 0, + description TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS classes ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + resource_name TEXT NOT NULL, + max_resource INTEGER NOT NULL DEFAULT 100, + theme_color TEXT NOT NULL DEFAULT '#e5b95f', + description TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS spells ( + id INTEGER PRIMARY KEY, + class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + spell_type TEXT NOT NULL, + resource_cost INTEGER NOT NULL, + cooldown_seconds REAL NOT NULL DEFAULT 0, + power INTEGER NOT NULL, + unlock_level INTEGER NOT NULL DEFAULT 1, + glyph TEXT NOT NULL DEFAULT '+', + description TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + slot TEXT NOT NULL CHECK (slot IN ('weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket', 'component')), + rarity TEXT NOT NULL, + item_level INTEGER NOT NULL, + healing_power INTEGER NOT NULL DEFAULT 0, + max_resource_bonus INTEGER NOT NULL DEFAULT 0, + glyph TEXT NOT NULL DEFAULT '?', + image_url TEXT NOT NULL DEFAULT '/equipment-placeholder.svg', + description TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS encounter_loot ( + encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + difficulty_id INTEGER REFERENCES difficulties(id), + drop_weight INTEGER NOT NULL DEFAULT 100, + drop_chance REAL NOT NULL DEFAULT 0.65 CHECK (drop_chance BETWEEN 0 AND 1), + PRIMARY KEY (encounter_id, difficulty_id, item_id) +); + +CREATE TABLE IF NOT EXISTS dungeon_completion_loot ( + dungeon_id INTEGER NOT NULL REFERENCES dungeons(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + PRIMARY KEY (dungeon_id, item_id) +); + +CREATE TABLE IF NOT EXISTS crafting_recipes ( + id INTEGER PRIMARY KEY, + item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE, + difficulty_id INTEGER REFERENCES difficulties(id), + source_dungeon_id INTEGER REFERENCES dungeons(id) ON DELETE CASCADE, + source_encounter_id INTEGER REFERENCES encounters(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS crafting_recipe_components ( + recipe_id INTEGER NOT NULL REFERENCES crafting_recipes(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL CHECK (quantity > 0), + PRIMARY KEY (recipe_id, item_id) +); + +CREATE TABLE IF NOT EXISTS item_sets ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS item_set_items ( + set_id INTEGER NOT NULL REFERENCES item_sets(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE, + PRIMARY KEY (set_id, item_id) +); + +CREATE TABLE IF NOT EXISTS item_set_bonuses ( + id INTEGER PRIMARY KEY, + set_id INTEGER NOT NULL REFERENCES item_sets(id) ON DELETE CASCADE, + required_pieces INTEGER NOT NULL CHECK (required_pieces > 0), + effect_type TEXT NOT NULL, + description TEXT NOT NULL, + UNIQUE (set_id, required_pieces) +); + +CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + password_salt TEXT NOT NULL, + completed_dungeon_parts INTEGER NOT NULL DEFAULT 0, + completed_raid_phases INTEGER NOT NULL DEFAULT 0, + created_ip TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS account_ip_allowances ( + ip_address TEXT PRIMARY KEY, + max_accounts INTEGER NOT NULL CHECK (max_accounts >= 1), + note TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS 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, + talent_points INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (account_id, class_id) +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + active_character_id INTEGER REFERENCES characters(id), + expires_at TEXT NOT NULL, + created_ip TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS sessions_token_hash_idx ON sessions(token_hash); +CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at); + +CREATE TABLE IF NOT EXISTS character_ability_slots ( + character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + slot_number INTEGER NOT NULL CHECK (slot_number BETWEEN 1 AND 6), + spell_id INTEGER REFERENCES spells(id), + PRIMARY KEY (character_id, slot_number), + UNIQUE (character_id, spell_id) +); + +CREATE TABLE IF NOT EXISTS talents ( + id INTEGER PRIMARY KEY, + class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + max_rank INTEGER NOT NULL DEFAULT 1, + tier INTEGER NOT NULL DEFAULT 1, + branch INTEGER NOT NULL DEFAULT 1, + prerequisite_talent_id INTEGER REFERENCES talents(id), + prerequisite_rank INTEGER NOT NULL DEFAULT 0, + effect_type TEXT NOT NULL DEFAULT 'placeholder', + effect_value_per_rank REAL NOT NULL DEFAULT 0, + glyph TEXT NOT NULL DEFAULT '+', + description TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS character_talents ( + character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + talent_id INTEGER NOT NULL REFERENCES talents(id) ON DELETE CASCADE, + rank INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (character_id, talent_id) +); + +CREATE TABLE IF NOT EXISTS level_progression ( + level INTEGER PRIMARY KEY CHECK (level BETWEEN 1 AND 25), + experience_required INTEGER NOT NULL, + talent_points_total INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS game_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS character_inventory ( + character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id), + quantity INTEGER NOT NULL DEFAULT 1, + equipped INTEGER NOT NULL DEFAULT 0 CHECK (equipped IN (0, 1)), + PRIMARY KEY (character_id, item_id) +); + +CREATE TABLE IF NOT EXISTS encounter_loot_rolls ( + id INTEGER PRIMARY KEY, + character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + run_token TEXT NOT NULL, + encounter_id INTEGER NOT NULL REFERENCES encounters(id), + difficulty_id INTEGER NOT NULL REFERENCES difficulties(id), + item_id INTEGER REFERENCES items(id), + dropped INTEGER NOT NULL CHECK (dropped IN (0, 1)), + was_duplicate INTEGER NOT NULL DEFAULT 0 CHECK (was_duplicate IN (0, 1)), + quantity_after INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (character_id, run_token, encounter_id, difficulty_id) +); + +CREATE TABLE IF NOT EXISTS encounter_loot_roll_items ( + roll_id INTEGER NOT NULL REFERENCES encounter_loot_rolls(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id), + quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0), + was_duplicate INTEGER NOT NULL DEFAULT 0 CHECK (was_duplicate IN (0, 1)), + quantity_after INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (roll_id, item_id) +); + +CREATE TABLE IF NOT EXISTS dungeon_runs ( + id INTEGER PRIMARY KEY, + character_id INTEGER NOT NULL REFERENCES characters(id), + dungeon_id INTEGER NOT NULL REFERENCES dungeons(id), + difficulty_id INTEGER REFERENCES difficulties(id), + result TEXT NOT NULL CHECK (result IN ('active', 'victory', 'defeat', 'abandoned')), + character_name TEXT NOT NULL DEFAULT '', + class_name TEXT NOT NULL DEFAULT '', + character_level INTEGER NOT NULL DEFAULT 1, + average_item_level REAL NOT NULL DEFAULT 1, + resource_spent INTEGER NOT NULL DEFAULT 0, + duration_seconds INTEGER NOT NULL DEFAULT 0, + leaderboard_eligible INTEGER NOT NULL DEFAULT 0 CHECK (leaderboard_eligible IN (0, 1)), + start_part INTEGER NOT NULL DEFAULT 1, + completed_parts INTEGER NOT NULL DEFAULT 1, + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TEXT +); diff --git a/db/seed.sql b/db/seed.sql new file mode 100644 index 0000000..dda95b2 --- /dev/null +++ b/db/seed.sql @@ -0,0 +1,1013 @@ +INSERT OR IGNORE INTO locations (id, slug, name, description) VALUES + (1, 'ember-wastes', 'The Ember Wastes', 'A scorched frontier surrounding a buried citadel.'), + (2, 'crown-caldera', 'The Crown Caldera', 'A volcanic basin surrounding the stronghold of the Ember Crown.'); + +INSERT OR IGNORE INTO dungeons + (id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description) +VALUES + (1, 1, 'ashen-halls', 'The Ashen Halls', 1, 'dungeon', 5, NULL, 125, 'Break the cinder cult before the old furnace awakens.'), + (2, 2, 'citadel-of-the-ember-crown', 'Citadel of the Ember Crown', 1, 'raid', 10, 10, 175, 'Lead ten allies through the caldera and break the Ember Crown across three phases.'); + +UPDATE dungeons +SET slug = 'bulldrome-hunting-ground', + name = 'Bulldrome Hunting Ground', + location_id = 1, + recommended_level = 1, + content_type = 'dungeon', + party_size = 5, + completion_item_level = NULL, + experience_reward = 125, + description = 'A three-boss hunt featuring Bulldrome, Yian Kut-Ku, and Rathian.' +WHERE id = 1; +UPDATE dungeons SET party_size = 10, completion_item_level = NULL, experience_reward = 175 +WHERE slug = 'citadel-of-the-ember-crown'; + +INSERT OR IGNORE INTO difficulties + (id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description) +VALUES + (1, 'initiate', 'Initiate', 5, 1, 1.0, 1.0, 1.0, 'Entry-level dungeon difficulty.'), + (2, 'veteran', 'Veteran', 10, 5, 1.35, 1.2, 1.5, 'Enemies deal more damage and drop stronger gear.'), + (3, 'champion', 'Champion', 15, 10, 1.7, 1.45, 2.2, 'Demanding encounters for developed characters.'), + (4, 'mythic', 'Mythic', 20, 15, 2.1, 1.75, 3.0, 'Endgame dungeon difficulty.'), + (5, 'ascendant', 'Ascendant', 25, 20, 2.6, 2.1, 4.0, 'The current pinnacle difficulty.'), + (101, 'raid-normal', 'Normal', 7, 1, 1.0, 1.0, 1.25, 'The opening raid difficulty, tuned for a ten-player party.'); + +UPDATE difficulties SET + dropped_item_level = CASE slug + WHEN 'raid-normal' THEN 10 ELSE dropped_item_level END, + unlock_level = CASE slug + WHEN 'initiate' THEN 1 WHEN 'veteran' THEN 5 WHEN 'champion' THEN 10 + WHEN 'mythic' THEN 15 WHEN 'ascendant' THEN 20 ELSE unlock_level END, + health_multiplier = CASE slug + WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 1.35 WHEN 'champion' THEN 1.7 + WHEN 'mythic' THEN 2.1 WHEN 'ascendant' THEN 2.6 ELSE health_multiplier END, + damage_multiplier = CASE slug + WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 1.2 WHEN 'champion' THEN 1.45 + WHEN 'mythic' THEN 1.75 WHEN 'ascendant' THEN 2.1 ELSE damage_multiplier END, + experience_multiplier = CASE slug + WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 1.5 WHEN 'champion' THEN 2.2 + WHEN 'mythic' THEN 3.0 WHEN 'ascendant' THEN 4.0 ELSE experience_multiplier END; + +INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id) VALUES + (1, 1), + (1, 2), + (1, 3), + (1, 4), + (1, 5), + (2, 101); + +INSERT OR IGNORE INTO encounters + (id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description) +VALUES + (1, 1, 1, 'ashfang-pack', 'Ashfang Pack', 'trash', 390, 13, 7, 24, 'Three beasts snap at random party members.'), + (2, 1, 2, 'cinder-adepts', 'Cinder Adepts', 'trash', 470, 16, 10, 25, 'Cultists pressure the tank and throw embers into the group.'), + (3, 1, 3, 'bulldrome', 'Bulldrome', 'boss', 820, 18, 13, 27, 'A charging wyvern tests the party with heavy tusk strikes.'), + (10, 1, 4, 'sootborn-guard', 'Sootborn Guard', 'trash', 420, 14, 8, 24, 'A hulking guard forged from living soot.'), + (11, 1, 5, 'ember-channelers', 'Ember Channelers', 'trash', 500, 17, 11, 25, 'Cultists channelling raw ember energy.'), + (12, 1, 6, 'yian-kut-ku', 'Yian Kut-Ku', 'boss', 880, 20, 14, 28, 'A frantic bird wyvern scatters flame and wingbeats through the party.'), + (20, 1, 7, 'cinder-colossus', 'Cinder Colossus', 'trash', 460, 15, 9, 25, 'An enormous construct of slag and anger.'), + (21, 1, 8, 'flame-wardens', 'Flame Wardens', 'trash', 530, 18, 12, 26, 'Elite guards of the furnace sanctum.'), + (22, 1, 9, 'rathian', 'Rathian', 'boss', 950, 22, 16, 30, 'The queen of the land punishes mistakes with poison and crushing sweeps.'), + (100, 2, 1, 'scorched-vanguard', 'Scorched Vanguard', 'trash', 1050, 17, 10, 48, 'The citadel vanguard splits its assault across the raid.'), + (101, 2, 2, 'pyrebinders', 'Pyrebinders', 'trash', 1220, 19, 12, 50, 'Battle-mages bind living flame to random allies.'), + (102, 2, 3, 'gatekeeper-arkon', 'Gatekeeper Arkon', 'boss', 2150, 22, 15, 53, 'Arkon seals the caldera gate behind waves of burning force.'), + (103, 2, 4, 'molten-behemoths', 'Molten Behemoths', 'trash', 1160, 18, 11, 49, 'Two molten beasts batter both tanks and trample the raid.'), + (104, 2, 5, 'crown-zealots', 'Crown Zealots', 'trash', 1320, 20, 13, 51, 'Fanatics ignite themselves to empower their allies.'), + (105, 2, 6, 'high-inquisitor-vael', 'High Inquisitor Vael', 'boss', 2320, 24, 16, 55, 'Vael brands the unworthy and calls down columns of fire.'), + (106, 2, 7, 'emberwing-brood', 'Emberwing Brood', 'trash', 1260, 19, 12, 51, 'A flock of emberwings dives through the raid in sequence.'), + (107, 2, 8, 'crownforged-sentinels', 'Crownforged Sentinels', 'trash', 1420, 21, 14, 53, 'Ancient sentinels awaken to defend the crown chamber.'), + (108, 2, 9, 'the-ember-crown', 'The Ember Crown', 'boss', 2550, 26, 18, 57, 'The living crown burns above its throne, demanding perfect coordination.'); + +UPDATE encounters +SET slug = 'bulldrome', + name = 'Bulldrome', + description = 'A charging wyvern tests the party with heavy tusk strikes.', + image_url = COALESCE(NULLIF(image_url, ''), '/boss-placeholder.svg') +WHERE id = 3; + +UPDATE encounters +SET slug = 'yian-kut-ku', + name = 'Yian Kut-Ku', + description = 'A frantic bird wyvern scatters flame and wingbeats through the party.', + image_url = COALESCE(NULLIF(image_url, ''), '/boss-placeholder.svg') +WHERE id = 12; + +UPDATE encounters +SET slug = 'rathian', + name = 'Rathian', + description = 'The queen of the land punishes mistakes with poison and crushing sweeps.', + image_url = COALESCE(NULLIF(image_url, ''), '/boss-placeholder.svg') +WHERE id = 22; + +INSERT OR IGNORE INTO mechanics + (id, encounter_id, name, mechanic_type, interval_seconds, power, description) +VALUES + (1, 3, 'Cinder Pulse', 'party_damage', 4.9, 12, 'Deals damage to the full party.'), + (2, 3, 'Searing Mark', 'dispellable_dot', 7.7, 7, 'Marks one target with recurring damage until cleansed.'), + (10, 12, 'Ember Wave', 'party_damage', 5.6, 14, 'A wave of ember energy hits the entire party.'), + (11, 12, 'Brand of Sacrifice', 'dispellable_dot', 8.4, 9, 'Brands a target with burning sigils.'), + (20, 22, 'Furnace Blast', 'party_damage', 6.3, 16, 'The furnace vents superheated air across the party.'), + (21, 22, 'Melting Touch', 'dispellable_dot', 9.1, 11, 'A target begins to melt from intense heat.'), + (100, 102, 'Caldera Shockwave', 'party_damage', 5.6, 14, 'A shockwave rolls across all ten raiders.'), + (101, 102, 'Gatebrand', 'dispellable_dot', 8.2, 9, 'Brands one raider with a seal of living fire.'), + (102, 105, 'Pillar of Judgment', 'party_damage', 5.2, 16, 'Columns of flame erupt beneath the raid.'), + (103, 105, 'Inquisitor''s Brand', 'dispellable_dot', 7.8, 10, 'A burning brand persists until cleansed.'), + (104, 108, 'Crownflare', 'party_damage', 4.9, 18, 'The Ember Crown releases a raid-wide flare.'), + (105, 108, 'Royal Decree', 'dispellable_dot', 7.1, 12, 'A lethal decree marks one raider for cleansing.'); + +INSERT OR IGNORE INTO classes + (id, slug, name, resource_name, max_resource, theme_color, description) +VALUES + (1, 'dawnweaver', 'Dawnweaver', 'Mana', 100, '#e5b95f', 'A reactive healer using radiant restoration and protective wards.'), + (2, 'lifebinder', 'Lifebinder', 'Bloom', 100, '#4fb978', 'A patient healer who grows powerful restorative effects over time.'), + (3, 'runesage', 'Runesage', 'Focus', 100, '#708bd6', 'A tactical healer who prepares runes, barriers, and delayed recovery.'); + +INSERT OR IGNORE INTO spells + (id, class_id, slug, name, spell_type, resource_cost, cooldown_seconds, power, unlock_level, glyph, description) +VALUES + (1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'), + (2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'), + (3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to every living party member.'), + (4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'), + (5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'), + (6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for the entire party.'), + (7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'), + (8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'), + (9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods the party with the full strength of dawn.'), + (20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'), + (21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'), + (22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads through the party.'), + (23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'), + (24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'), + (25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'), + (30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'), + (31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'), + (32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links the party through a shared healing pattern.'), + (33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'), + (34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'), + (35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.'); + +UPDATE spells SET resource_cost = CASE slug + WHEN 'mend' THEN 5 + WHEN 'renew' THEN 7 + WHEN 'radiance' THEN 12 + WHEN 'sun-ward' THEN 8 + WHEN 'purify' THEN 5 + WHEN 'dawn-burst' THEN 16 + WHEN 'guardian-light' THEN 13 + WHEN 'second-sun' THEN 20 + WHEN 'daybreak' THEN 23 + WHEN 'verdant-touch' THEN 5 + WHEN 'seed-of-life' THEN 7 + WHEN 'wild-bloom' THEN 12 + WHEN 'barkskin' THEN 8 + WHEN 'purging-sap' THEN 5 + WHEN 'ancient-grove' THEN 17 + WHEN 'etched-mend' THEN 5 + WHEN 'echo-rune' THEN 7 + WHEN 'concordance' THEN 12 + WHEN 'aegis-script' THEN 8 + WHEN 'unravel' THEN 5 + WHEN 'grand-design' THEN 16 + ELSE resource_cost +END; + +UPDATE spells SET unlock_level = 1, glyph = '+' WHERE slug = 'mend'; +UPDATE spells SET unlock_level = 1, glyph = '~' WHERE slug = 'renew'; +UPDATE spells SET unlock_level = 1, glyph = '*' WHERE slug = 'radiance'; +UPDATE spells SET unlock_level = 1, glyph = 'O' WHERE slug = 'sun-ward'; +UPDATE spells SET unlock_level = 1, glyph = 'x' WHERE slug = 'purify'; +UPDATE spells SET cooldown_seconds = 0.5 +WHERE slug IN ('mend', 'renew', 'verdant-touch', 'seed-of-life', 'etched-mend', 'echo-rune'); +UPDATE spells SET unlock_level = 5 WHERE slug IN ('dawn-burst', 'ancient-grove', 'grand-design'); +UPDATE spells SET unlock_level = 10 WHERE slug = 'guardian-light'; +UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun'; +UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak'; + +INSERT OR IGNORE INTO items + (id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description) +VALUES + (1, 'emberglass-sigil', 'Emberglass Sigil', 'ring', 'rare', 5, 4, 5, 'o', 'Warm light moves inside the cracked glass.'), + (2, 'wardens-cinderwrap', 'Warden''s Cinderwrap', 'chest', 'uncommon', 5, 3, 0, 'C', 'Robes threaded with fire-resistant fibers.'), + (3, 'ashwood-crook', 'Ashwood Crook', 'weapon', 'uncommon', 5, 5, 0, '/', 'A healer''s staff carved from fire-hardened wood.'), + (4, 'cinderstep-boots', 'Cinderstep Boots', 'boots', 'uncommon', 5, 3, 0, 'b', 'Light boots made for crossing burning ground.'), + (5, 'adepts-hood', 'Adept''s Hood', 'helmet', 'uncommon', 5, 3, 4, '^', 'The inner cloth is covered in careful annotations.'), + (6, 'furnace-tenders-wraps', 'Furnace Tender''s Wraps', 'gloves', 'uncommon', 5, 3, 2, 'g', 'Heat-scored gloves that still hum with careful magic.'), + (7, 'warden-ember', 'Warden''s Ember', 'trinket', 'rare', 5, 4, 4, '*', 'A coal from the old furnace that refuses to cool.'), + (201, 'tempered-emberglass-sigil', 'Tempered Emberglass Sigil', 'ring', 'rare', 10, 7, 9, 'o', 'The glass has been tempered by Veteran flame.'), + (202, 'tempered-cinderwrap', 'Tempered Cinderwrap', 'chest', 'uncommon', 10, 7, 2, 'C', 'Reinforced robes woven for Veteran expeditions.'), + (203, 'tempered-ashwood-crook', 'Tempered Ashwood Crook', 'weapon', 'uncommon', 10, 10, 2, '/', 'A fire-hardened staff balanced for Veteran healers.'), + (204, 'tempered-cinderstep-boots', 'Tempered Cinderstep Boots', 'boots', 'uncommon', 10, 6, 5, 'b', 'Sturdy boots for crossing hotter ground.'), + (205, 'tempered-adepts-hood', 'Tempered Adept''s Hood', 'helmet', 'uncommon', 10, 6, 6, '^', 'Its annotations glow when danger approaches.'), + (206, 'tempered-furnace-wraps', 'Tempered Furnace Wraps', 'gloves', 'uncommon', 10, 7, 4, 'g', 'Veteran wraps stitched with ember-resistant thread.'), + (207, 'tempered-warden-ember', 'Tempered Warden''s Ember', 'trinket', 'rare', 10, 8, 7, '*', 'A stable ember pulsing with restorative heat.'), + (301, 'runed-emberglass-sigil', 'Runed Emberglass Sigil', 'ring', 'rare', 15, 10, 13, 'o', 'Champion runes swim beneath the emberglass.'), + (302, 'runed-cinderwrap', 'Runed Cinderwrap', 'chest', 'rare', 15, 11, 3, 'C', 'Champion robes covered in protective script.'), + (303, 'runed-ashwood-crook', 'Runed Ashwood Crook', 'weapon', 'rare', 15, 15, 3, '/', 'A Champion focus carved with radiant channels.'), + (304, 'runed-cinderstep-boots', 'Runed Cinderstep Boots', 'boots', 'rare', 15, 9, 8, 'b', 'Each step leaves a fading restorative rune.'), + (305, 'runed-adepts-hood', 'Runed Adept''s Hood', 'helmet', 'rare', 15, 9, 9, '^', 'A Champion hood that sharpens restorative focus.'), + (306, 'runed-furnace-wraps', 'Runed Furnace Wraps', 'gloves', 'rare', 15, 11, 6, 'g', 'Runes brighten as healing magic passes through them.'), + (307, 'runed-warden-ember', 'Runed Warden''s Ember', 'trinket', 'rare', 15, 12, 10, '*', 'A Champion ember bound by concentric runes.'), + (401, 'mythic-emberglass-sigil', 'Mythic Emberglass Sigil', 'ring', 'epic', 20, 14, 17, 'o', 'A flawless sigil carrying the furnace''s true name.'), + (402, 'mythic-cinderwrap', 'Mythic Cinderwrap', 'chest', 'epic', 20, 15, 4, 'C', 'Mythic vestments untouched by ordinary flame.'), + (403, 'mythic-ashwood-crook', 'Mythic Ashwood Crook', 'weapon', 'epic', 20, 20, 4, '/', 'A Mythic focus cut from the oldest ashwood.'), + (404, 'mythic-cinderstep-boots', 'Mythic Cinderstep Boots', 'boots', 'epic', 20, 12, 11, 'b', 'The wearer walks safely across living fire.'), + (405, 'mythic-adepts-hood', 'Mythic Adept''s Hood', 'helmet', 'epic', 20, 12, 12, '^', 'A Mythic hood filled with whispered formulae.'), + (406, 'mythic-furnace-wraps', 'Mythic Furnace Wraps', 'gloves', 'epic', 20, 15, 8, 'g', 'Mythic gloves that shape healing into precise patterns.'), + (407, 'mythic-warden-ember', 'Mythic Warden''s Ember', 'trinket', 'epic', 20, 16, 13, '*', 'The furnace heart answers this ember.'), + (501, 'ascendant-emberglass-sigil', 'Ascendant Emberglass Sigil', 'ring', 'epic', 25, 18, 21, 'o', 'An Ascendant sigil radiant with contained dawn.'), + (502, 'ascendant-cinderwrap', 'Ascendant Cinderwrap', 'chest', 'epic', 25, 19, 5, 'C', 'Ascendant robes woven from fire and light.'), + (503, 'ascendant-ashwood-crook', 'Ascendant Ashwood Crook', 'weapon', 'epic', 25, 25, 5, '/', 'An Ascendant staff bearing an unending ember.'), + (504, 'ascendant-cinderstep-boots', 'Ascendant Cinderstep Boots', 'boots', 'epic', 25, 15, 14, 'b', 'The ground cools before each Ascendant step.'), + (505, 'ascendant-adepts-hood', 'Ascendant Adept''s Hood', 'helmet', 'epic', 25, 15, 15, '^', 'Every healing pattern appears obvious beneath this hood.'), + (506, 'ascendant-furnace-wraps', 'Ascendant Furnace Wraps', 'gloves', 'epic', 25, 19, 10, 'g', 'Ascendant gloves threaded with concentrated daylight.'), + (507, 'ascendant-warden-ember', 'Ascendant Warden''s Ember', 'trinket', 'epic', 25, 20, 16, '*', 'A perfect ember from the heart of the Ashen Halls.'), + (701, 'caldera-signet', 'Caldera Signet', 'ring', 'rare', 7, 5, 6, 'o', 'A raid-forged signet warm with caldera light.'), + (702, 'vanguard-mantle', 'Vanguard Mantle', 'chest', 'rare', 7, 5, 1, 'C', 'A reinforced mantle taken from the citadel vanguard.'), + (703, 'pyrebinder-crook', 'Pyrebinder Crook', 'weapon', 'rare', 7, 7, 1, '/', 'A focus used to bend living flame toward restoration.'), + (704, 'emberstep-treads', 'Emberstep Treads', 'boots', 'rare', 7, 4, 5, 'b', 'Raid treads made for crossing unstable volcanic stone.'), + (705, 'gatekeeper-cowl', 'Gatekeeper Cowl', 'helmet', 'rare', 7, 4, 6, '^', 'A cowl inscribed with the wards of the outer gate.'), + (706, 'crownward-wraps', 'Crownward Wraps', 'gloves', 'rare', 7, 5, 3, 'g', 'Precise wraps worn by the healers of the Ember Crown.'), + (707, 'living-coal-charm', 'Living Coal Charm', 'trinket', 'rare', 7, 6, 5, '*', 'A coal that pulses in time with nearby heartbeats.'), + (710, 'royal-caldera-signet', 'Royal Caldera Signet', 'ring', 'epic', 10, 8, 9, 'o', 'A royal signet claimed after breaking the Ember Crown.'), + (711, 'ember-crown-vestment', 'Ember Crown Vestment', 'chest', 'epic', 10, 8, 2, 'C', 'The ceremonial vestment of the fallen crown court.'), + (712, 'crownshard-crook', 'Crownshard Crook', 'weapon', 'epic', 10, 11, 2, '/', 'A healing focus set with a cooled shard of the crown.'), + (713, 'caldera-walkers', 'Caldera Walkers', 'boots', 'epic', 10, 7, 8, 'b', 'Boots that remain cool even at the caldera''s heart.'), + (714, 'inquisitors-cowl', 'Inquisitor''s Cowl', 'helmet', 'epic', 10, 7, 9, '^', 'The cowl of an inquisitor, stripped of its cruel sigils.'), + (715, 'royal-flame-wraps', 'Royal Flame Wraps', 'gloves', 'epic', 10, 8, 6, 'g', 'Royal wraps that shape flame into restorative patterns.'), + (716, 'extinguished-crown', 'Extinguished Crown', 'trinket', 'epic', 10, 9, 8, '*', 'A harmless fragment of the once-living Ember Crown.'), + (100, 'novice-crook', 'Novice Crook', 'weapon', 'common', 1, 1, 0, '/', 'A simple focus carried by every new healer.'), + (101, 'novice-cowl', 'Novice Cowl', 'helmet', 'common', 1, 0, 1, '^', 'Plain cloth that helps steady a beginner''s thoughts.'), + (102, 'novice-vestment', 'Novice Vestment', 'chest', 'common', 1, 1, 0, 'C', 'Unadorned robes issued to apprentice healers.'), + (103, 'novice-wraps', 'Novice Wraps', 'gloves', 'common', 1, 1, 0, 'g', 'Soft wraps that leave the fingers free for spellwork.'), + (104, 'novice-slippers', 'Novice Slippers', 'boots', 'common', 1, 0, 1, 'b', 'Comfortable enough for a first expedition.'), + (105, 'novice-band', 'Novice Band', 'ring', 'common', 1, 0, 1, 'o', 'A copper band etched with a minor restorative rune.'), + (106, 'novice-token', 'Novice Token', 'trinket', 'common', 1, 1, 0, '*', 'A small charm commemorating the healer''s oath.'), + (107, 'novice-wand', 'Novice Wand', 'weapon', 'common', 1, 0, 2, '!', 'A spare focus that favors a deeper resource pool.'), + (600, 'minor-component', 'Minor Component', 'component', 'common', 1, 0, 0, '◆', 'A basic crafting component.'), + (601, 'basic-component', 'Basic Component', 'component', 'common', 5, 0, 0, '◇', 'A standard crafting component.'), + (602, 'refined-component', 'Refined Component', 'component', 'common', 10, 0, 0, '◈', 'A refined crafting component.'), + (603, 'advanced-component', 'Advanced Component', 'component', 'common', 15, 0, 0, '◉', 'An advanced crafting component.'), + (604, 'superior-component', 'Superior Component', 'component', 'common', 20, 0, 0, '◎', 'A superior crafting component.'), + (605, 'primal-component', 'Primal Component', 'component', 'common', 25, 0, 0, '✦', 'A primal crafting component.'); + +INSERT OR IGNORE INTO items + (id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description) +VALUES + (8, 'ashwalker-legwraps', 'Ashwalker Legwraps', 'pants', 'uncommon', 5, 3, 3, 'P', 'Layered legwraps brushed clean of furnace ash.'), + (9, 'sootglass-pendant', 'Sootglass Pendant', 'necklace', 'rare', 5, 4, 4, 'n', 'A pendant made from glass cooled in living soot.'), + (208, 'tempered-ashwalker-legwraps', 'Tempered Ashwalker Legwraps', 'pants', 'uncommon', 10, 6, 6, 'P', 'Veteran legwraps stitched with tempered ember thread.'), + (209, 'tempered-sootglass-pendant', 'Tempered Sootglass Pendant', 'necklace', 'rare', 10, 8, 7, 'n', 'The sootglass holds a steadier restorative glow.'), + (308, 'runed-ashwalker-legwraps', 'Runed Ashwalker Legwraps', 'pants', 'rare', 15, 9, 9, 'P', 'Champion runes keep each step measured and sure.'), + (309, 'runed-sootglass-pendant', 'Runed Sootglass Pendant', 'necklace', 'rare', 15, 12, 10, 'n', 'Fine runes circle the sootglass pendant.'), + (408, 'mythic-ashwalker-legwraps', 'Mythic Ashwalker Legwraps', 'pants', 'epic', 20, 12, 12, 'P', 'Mythic legwraps that shed molten grit.'), + (409, 'mythic-sootglass-pendant', 'Mythic Sootglass Pendant', 'necklace', 'epic', 20, 16, 13, 'n', 'A flawless pendant carrying furnace-born warmth.'), + (508, 'ascendant-ashwalker-legwraps', 'Ascendant Ashwalker Legwraps', 'pants', 'epic', 25, 15, 15, 'P', 'Ascendant legwraps woven from fireproof daylight.'), + (509, 'ascendant-sootglass-pendant', 'Ascendant Sootglass Pendant', 'necklace', 'epic', 25, 20, 16, 'n', 'The sootglass burns with a contained dawn.'), + (708, 'caldera-legwraps', 'Caldera Legwraps', 'pants', 'rare', 7, 4, 6, 'P', 'Raid legwraps made for unstable volcanic stone.'), + (709, 'gateflame-pendant', 'Gateflame Pendant', 'necklace', 'rare', 7, 6, 5, 'n', 'A pendant bearing a cooled mote of gateflame.'), + (717, 'royal-caldera-legwraps', 'Royal Caldera Legwraps', 'pants', 'epic', 10, 7, 9, 'P', 'Royal legwraps recovered from the Ember Crown court.'), + (718, 'ember-crown-pendant', 'Ember Crown Pendant', 'necklace', 'epic', 10, 9, 8, 'n', 'A pendant set with a harmless shard of the Ember Crown.'), + (108, 'novice-trousers', 'Novice Trousers', 'pants', 'common', 1, 0, 1, 'P', 'Plain trousers for a first expedition.'), + (109, 'novice-pendant', 'Novice Pendant', 'necklace', 'common', 1, 1, 0, 'n', 'A simple charm worn by apprentice healers.'), + (800, 'ashen-cowl-pattern', 'Ashen Cowl Pattern', 'component', 'common', 5, 0, 0, 'H', 'A Warden Vhal pattern used to craft helmets.'), + (801, 'ashen-vestment-pattern', 'Ashen Vestment Pattern', 'component', 'common', 5, 0, 0, 'C', 'A Warden Vhal pattern used to craft chest pieces.'), + (802, 'ashen-wrap-pattern', 'Ashen Wrap Pattern', 'component', 'common', 5, 0, 0, 'G', 'A Warden Vhal pattern used to craft gloves.'), + (803, 'cinderstep-pattern', 'Cinderstep Pattern', 'component', 'common', 5, 0, 0, 'B', 'A Forge-Priestess Haela pattern used to craft boots.'), + (804, 'emberglass-setting', 'Emberglass Setting', 'component', 'common', 5, 0, 0, 'R', 'A Forge-Priestess Haela setting used to craft rings.'), + (805, 'warden-ember-core', 'Warden Ember Core', 'component', 'common', 5, 0, 0, 'T', 'A Forge-Priestess Haela core used to craft trinkets.'), + (806, 'ashwood-focus-pattern', 'Ashwood Focus Pattern', 'component', 'common', 5, 0, 0, 'W', 'An Old Furnace pattern used to craft weapons.'), + (807, 'furnace-legwrap-pattern', 'Furnace Legwrap Pattern', 'component', 'common', 5, 0, 0, 'L', 'An Old Furnace pattern used to craft pants.'), + (808, 'sootglass-pendant-pattern', 'Sootglass Pendant Pattern', 'component', 'common', 5, 0, 0, 'N', 'An Old Furnace pattern used to craft necklaces.'), + (820, 'vhal-emberplate', 'Vhal Emberplate', 'component', 'uncommon', 5, 0, 0, 'V', 'A boss material from Warden Vhal.'), + (821, 'haela-forgebrand', 'Haela Forgebrand', 'component', 'uncommon', 5, 0, 0, 'F', 'A boss material from Forge-Priestess Haela.'), + (822, 'old-furnace-heartshard', 'Old Furnace Heartshard', 'component', 'uncommon', 5, 0, 0, 'O', 'A boss material from the Old Furnace.'), + (830, 'gatekeeper-cowl-pattern', 'Gatekeeper Cowl Pattern', 'component', 'rare', 10, 0, 0, 'H', 'A Gatekeeper Arkon pattern used to craft raid helmets.'), + (831, 'crownward-vestment-pattern', 'Crownward Vestment Pattern', 'component', 'rare', 10, 0, 0, 'C', 'A Gatekeeper Arkon pattern used to craft raid chest pieces.'), + (832, 'crownward-wrap-pattern', 'Crownward Wrap Pattern', 'component', 'rare', 10, 0, 0, 'G', 'A Gatekeeper Arkon pattern used to craft raid gloves.'), + (833, 'caldera-tread-pattern', 'Caldera Tread Pattern', 'component', 'rare', 10, 0, 0, 'B', 'A High Inquisitor Vael pattern used to craft raid boots.'), + (834, 'royal-signet-setting', 'Royal Signet Setting', 'component', 'rare', 10, 0, 0, 'R', 'A High Inquisitor Vael setting used to craft raid rings.'), + (835, 'living-coal-vessel', 'Living Coal Vessel', 'component', 'rare', 10, 0, 0, 'T', 'A High Inquisitor Vael vessel used to craft raid trinkets.'), + (836, 'crownshard-focus-pattern', 'Crownshard Focus Pattern', 'component', 'rare', 10, 0, 0, 'W', 'An Ember Crown pattern used to craft raid weapons.'), + (837, 'inquisitor-legwrap-pattern', 'Inquisitor Legwrap Pattern', 'component', 'rare', 10, 0, 0, 'L', 'An Ember Crown pattern used to craft raid pants.'), + (838, 'crown-pendant-pattern', 'Crown Pendant Pattern', 'component', 'rare', 10, 0, 0, 'N', 'An Ember Crown pattern used to craft raid necklaces.'), + (850, 'arkon-gate-sigil', 'Arkon Gate Sigil', 'component', 'rare', 10, 0, 0, 'A', 'A raid boss material from Gatekeeper Arkon.'), + (851, 'vael-brandseal', 'Vael Brandseal', 'component', 'rare', 10, 0, 0, 'I', 'A raid boss material from High Inquisitor Vael.'), + (852, 'ember-crown-shard', 'Ember Crown Shard', 'component', 'rare', 10, 0, 0, 'E', 'A raid boss material from the Ember Crown.'), + (860, 'bulldrome-hide', 'Bulldrome Hide', 'component', 'uncommon', 5, 0, 0, 'H', 'A rugged hide from Bulldrome.'), + (861, 'bullfango-head', 'Bullfango Head', 'component', 'rare', 5, 0, 0, 'F', 'A rare head taken from a Bullfango.'), + (862, 'bulldrome-tusk', 'Bulldrome Tusk', 'component', 'uncommon', 5, 0, 0, 'T', 'A heavy tusk from Bulldrome.'), + (863, 'giant-bone', 'Giant Bone', 'component', 'uncommon', 5, 0, 0, 'B', 'A giant bone used in sturdy crafting.'), + (864, 'kut-ku-webbing', 'Kut-Ku Webbing', 'component', 'uncommon', 5, 0, 0, 'W', 'Flexible webbing from Yian Kut-Ku.'), + (865, 'kut-ku-shell', 'Kut-Ku Shell', 'component', 'uncommon', 5, 0, 0, 'S', 'A bright shell from Yian Kut-Ku.'), + (866, 'kut-ku-scale', 'Kut-Ku Scale', 'component', 'uncommon', 5, 0, 0, 'K', 'A scale from Yian Kut-Ku.'), + (867, 'giant-beak', 'Giant Beak', 'component', 'rare', 5, 0, 0, 'G', 'A rare giant beak from Yian Kut-Ku.'), + (868, 'rathian-shell', 'Rathian Shell', 'component', 'uncommon', 5, 0, 0, 'R', 'A durable shell from Rathian.'), + (869, 'rathian-scale-x2', 'Rathian Scale x2', 'component', 'uncommon', 5, 0, 0, '2', 'A paired Rathian scale reward.'), + (870, 'rathian-spike', 'Rathian Spike', 'component', 'rare', 5, 0, 0, 'P', 'A sharp spike from Rathian.'), + (871, 'rathian-plate', 'Rathian Plate', 'component', 'epic', 5, 0, 0, '*', 'A prized Rathian plate.'); + +UPDATE items SET slot = 'ring', item_level = 5 WHERE slug = 'emberglass-sigil'; +UPDATE items SET item_level = 5 WHERE slug = 'wardens-cinderwrap'; +UPDATE items SET glyph = 'o' WHERE slug = 'emberglass-sigil'; +UPDATE items SET glyph = 'C' WHERE slug = 'wardens-cinderwrap'; +UPDATE items SET glyph = '/' WHERE slug = 'ashwood-crook'; +UPDATE items SET glyph = 'b' WHERE slug = 'cinderstep-boots'; +UPDATE items SET glyph = '^' WHERE slug = 'adepts-hood'; + +DELETE FROM encounter_loot; + +INSERT INTO encounter_loot + (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) +VALUES + (3, 860, 1, 50, 1.0), (3, 861, 1, 5, 1.0), (3, 862, 1, 15, 1.0), (3, 863, 1, 30, 1.0), + (3, 860, 2, 50, 1.0), (3, 861, 2, 5, 1.0), (3, 862, 2, 15, 1.0), (3, 863, 2, 30, 1.0), + (3, 860, 3, 50, 1.0), (3, 861, 3, 5, 1.0), (3, 862, 3, 15, 1.0), (3, 863, 3, 30, 1.0), + (3, 860, 4, 50, 1.0), (3, 861, 4, 5, 1.0), (3, 862, 4, 15, 1.0), (3, 863, 4, 30, 1.0), + (3, 860, 5, 50, 1.0), (3, 861, 5, 5, 1.0), (3, 862, 5, 15, 1.0), (3, 863, 5, 30, 1.0), + (12, 864, 1, 32, 1.0), (12, 865, 1, 50, 1.0), (12, 866, 1, 13, 1.0), (12, 867, 1, 5, 1.0), + (12, 864, 2, 32, 1.0), (12, 865, 2, 50, 1.0), (12, 866, 2, 13, 1.0), (12, 867, 2, 5, 1.0), + (12, 864, 3, 32, 1.0), (12, 865, 3, 50, 1.0), (12, 866, 3, 13, 1.0), (12, 867, 3, 5, 1.0), + (12, 864, 4, 32, 1.0), (12, 865, 4, 50, 1.0), (12, 866, 4, 13, 1.0), (12, 867, 4, 5, 1.0), + (12, 864, 5, 32, 1.0), (12, 865, 5, 50, 1.0), (12, 866, 5, 13, 1.0), (12, 867, 5, 5, 1.0), + (22, 868, 1, 36, 1.0), (22, 869, 1, 52, 1.0), (22, 870, 1, 10, 1.0), (22, 871, 1, 2, 1.0), + (22, 868, 2, 36, 1.0), (22, 869, 2, 52, 1.0), (22, 870, 2, 10, 1.0), (22, 871, 2, 2, 1.0), + (22, 868, 3, 36, 1.0), (22, 869, 3, 52, 1.0), (22, 870, 3, 10, 1.0), (22, 871, 3, 2, 1.0), + (22, 868, 4, 36, 1.0), (22, 869, 4, 52, 1.0), (22, 870, 4, 10, 1.0), (22, 871, 4, 2, 1.0), + (22, 868, 5, 36, 1.0), (22, 869, 5, 52, 1.0), (22, 870, 5, 10, 1.0), (22, 871, 5, 2, 1.0), + (102, 602, 101, 24, 1.0), (102, 850, 101, 22, 1.0), (102, 830, 101, 18, 1.0), (102, 831, 101, 18, 1.0), (102, 832, 101, 18, 1.0), + (105, 602, 101, 24, 1.0), (105, 851, 101, 22, 1.0), (105, 833, 101, 18, 1.0), (105, 834, 101, 18, 1.0), (105, 835, 101, 18, 1.0), + (108, 602, 101, 24, 1.0), (108, 852, 101, 22, 1.0), (108, 836, 101, 18, 1.0), (108, 837, 101, 18, 1.0), (108, 838, 101, 18, 1.0); + +DELETE FROM crafting_recipe_components; +DELETE FROM crafting_recipes; + +INSERT INTO crafting_recipes + (id, item_id, difficulty_id, source_dungeon_id, source_encounter_id) +VALUES + (1001, 5, 1, 1, 3), (1002, 2, 1, 1, 3), (1003, 6, 1, 1, 3), + (1004, 4, 1, 1, 12), (1005, 1, 1, 1, 12), (1006, 7, 1, 1, 12), + (1007, 3, 1, 1, 22), (1008, 8, 1, 1, 22), (1009, 9, 1, 1, 22), + (1101, 205, 2, 1, 3), (1102, 202, 2, 1, 3), (1103, 206, 2, 1, 3), + (1104, 204, 2, 1, 12), (1105, 201, 2, 1, 12), (1106, 207, 2, 1, 12), + (1107, 203, 2, 1, 22), (1108, 208, 2, 1, 22), (1109, 209, 2, 1, 22), + (1201, 305, 3, 1, 3), (1202, 302, 3, 1, 3), (1203, 306, 3, 1, 3), + (1204, 304, 3, 1, 12), (1205, 301, 3, 1, 12), (1206, 307, 3, 1, 12), + (1207, 303, 3, 1, 22), (1208, 308, 3, 1, 22), (1209, 309, 3, 1, 22), + (1301, 405, 4, 1, 3), (1302, 402, 4, 1, 3), (1303, 406, 4, 1, 3), + (1304, 404, 4, 1, 12), (1305, 401, 4, 1, 12), (1306, 407, 4, 1, 12), + (1307, 403, 4, 1, 22), (1308, 408, 4, 1, 22), (1309, 409, 4, 1, 22), + (1401, 505, 5, 1, 3), (1402, 502, 5, 1, 3), (1403, 506, 5, 1, 3), + (1404, 504, 5, 1, 12), (1405, 501, 5, 1, 12), (1406, 507, 5, 1, 12), + (1407, 503, 5, 1, 22), (1408, 508, 5, 1, 22), (1409, 509, 5, 1, 22), + (2001, 714, 101, 2, 102), (2002, 711, 101, 2, 102), (2003, 715, 101, 2, 102), + (2004, 713, 101, 2, 105), (2005, 710, 101, 2, 105), (2006, 716, 101, 2, 105), + (2007, 712, 101, 2, 108), (2008, 717, 101, 2, 108), (2009, 718, 101, 2, 108); + +WITH recipe_requirements(recipe_id, tier_component_id, tier_qty, boss_component_id, pattern_component_id) AS ( + VALUES + (1001, 601, 1, 820, 800), (1002, 601, 1, 820, 801), (1003, 601, 1, 820, 802), + (1004, 601, 1, 821, 803), (1005, 601, 1, 821, 804), (1006, 601, 1, 821, 805), + (1007, 601, 1, 822, 806), (1008, 601, 1, 822, 807), (1009, 601, 1, 822, 808), + (1101, 602, 2, 820, 800), (1102, 602, 2, 820, 801), (1103, 602, 2, 820, 802), + (1104, 602, 2, 821, 803), (1105, 602, 2, 821, 804), (1106, 602, 2, 821, 805), + (1107, 602, 2, 822, 806), (1108, 602, 2, 822, 807), (1109, 602, 2, 822, 808), + (1201, 603, 3, 820, 800), (1202, 603, 3, 820, 801), (1203, 603, 3, 820, 802), + (1204, 603, 3, 821, 803), (1205, 603, 3, 821, 804), (1206, 603, 3, 821, 805), + (1207, 603, 3, 822, 806), (1208, 603, 3, 822, 807), (1209, 603, 3, 822, 808), + (1301, 604, 4, 820, 800), (1302, 604, 4, 820, 801), (1303, 604, 4, 820, 802), + (1304, 604, 4, 821, 803), (1305, 604, 4, 821, 804), (1306, 604, 4, 821, 805), + (1307, 604, 4, 822, 806), (1308, 604, 4, 822, 807), (1309, 604, 4, 822, 808), + (1401, 605, 5, 820, 800), (1402, 605, 5, 820, 801), (1403, 605, 5, 820, 802), + (1404, 605, 5, 821, 803), (1405, 605, 5, 821, 804), (1406, 605, 5, 821, 805), + (1407, 605, 5, 822, 806), (1408, 605, 5, 822, 807), (1409, 605, 5, 822, 808), + (2001, 602, 3, 850, 830), (2002, 602, 3, 850, 831), (2003, 602, 3, 850, 832), + (2004, 602, 3, 851, 833), (2005, 602, 3, 851, 834), (2006, 602, 3, 851, 835), + (2007, 602, 3, 852, 836), (2008, 602, 3, 852, 837), (2009, 602, 3, 852, 838) +) +INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) +SELECT recipe_id, tier_component_id, tier_qty FROM recipe_requirements +UNION ALL +SELECT recipe_id, boss_component_id, 1 FROM recipe_requirements +UNION ALL +SELECT recipe_id, pattern_component_id, 1 FROM recipe_requirements; + +DELETE FROM item_set_bonuses; +DELETE FROM item_set_items; +DELETE FROM item_sets; + +INSERT INTO item_sets (id, slug, name, description) VALUES + (1, 'ember-crown-regalia', 'Ember Crown Regalia', 'A raid set crafted from Citadel of the Ember Crown materials.'); + +INSERT INTO item_set_items (set_id, item_id) VALUES + (1, 710), (1, 711), (1, 712), (1, 713), (1, 714), (1, 715), (1, 716), (1, 717), (1, 718); + +INSERT INTO item_set_bonuses + (id, set_id, required_pieces, effect_type, description) +VALUES + (1, 1, 2, 'mend_extra_target', 'Mend heals 1 additional target.'), + (2, 1, 4, 'renew_extra_target', 'Renew is applied to 1 additional target.'), + (3, 1, 7, 'mend_applies_renew', 'Casting Mend on a target applies Renew.'); + +DELETE FROM dungeon_completion_loot; + +INSERT OR IGNORE INTO characters (id, class_id, name, level, experience) VALUES + (1, 1, 'Mira', 1, 0), + (2, 2, 'Mira', 1, 0), + (3, 3, 'Mira', 1, 0); + +INSERT OR IGNORE INTO character_ability_slots (character_id, slot_number, spell_id) VALUES + (1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), (1, 6, NULL), + (2, 1, 20), (2, 2, 21), (2, 3, 22), (2, 4, 23), (2, 5, 24), (2, 6, NULL), + (3, 1, 30), (3, 2, 31), (3, 3, 32), (3, 4, 33), (3, 5, 34), (3, 6, NULL); + +INSERT OR IGNORE INTO character_inventory (character_id, item_id, quantity, equipped) VALUES + (1, 100, 1, 1), (1, 101, 1, 1), (1, 102, 1, 1), (1, 103, 1, 1), + (1, 104, 1, 1), (1, 108, 1, 1), (1, 105, 1, 1), (1, 109, 1, 1), (1, 106, 1, 1), (1, 107, 1, 0), + (2, 100, 1, 1), (2, 101, 1, 1), (2, 102, 1, 1), (2, 103, 1, 1), + (2, 104, 1, 1), (2, 108, 1, 1), (2, 105, 1, 1), (2, 109, 1, 1), (2, 106, 1, 1), (2, 107, 1, 0), + (3, 100, 1, 1), (3, 101, 1, 1), (3, 102, 1, 1), (3, 103, 1, 1), + (3, 104, 1, 1), (3, 108, 1, 1), (3, 105, 1, 1), (3, 109, 1, 1), (3, 106, 1, 1), (3, 107, 1, 0); + +INSERT OR IGNORE INTO talents + (id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description) +VALUES + (1, 1, 'bright-reserves', 'Bright Reserves', 5, 1, 1, NULL, 0, 'max_resource', 2, 'M', 'Increases maximum Mana by 2 per rank.'), + (2, 1, 'gentle-dawn', 'Gentle Dawn', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, '~', 'Increases healing-over-time power by 2% per rank.'), + (10, 1, 'steady-hands', 'Steady Hands', 5, 1, 3, NULL, 0, 'direct_heal_percent', 2, '+', 'Increases direct healing by 2% per rank.'), + (11, 1, 'overflowing-light', 'Overflowing Light', 5, 2, 1, 1, 3, 'resource_regen_percent', 2, 'O', 'Improves Mana regeneration by 2% per rank. Requires Bright Reserves rank 3.'), + (12, 1, 'lingering-rays', 'Lingering Rays', 5, 2, 2, 2, 3, 'hot_duration_percent', 4, 'L', 'Extends healing-over-time duration by 4% per rank. Requires Gentle Dawn rank 3.'), + (13, 1, 'radiant-precision', 'Radiant Precision', 5, 2, 3, 10, 3, 'critical_heal_percent', 1, '!', 'Adds 1% healing critical chance per rank. Requires Steady Hands rank 3.'), + (14, 1, 'sunlit-aegis', 'Sunlit Aegis', 3, 3, 1, 11, 5, 'absorb_power_percent', 5, 'A', 'Strengthens absorb effects by 5% per rank. Requires Overflowing Light rank 5.'), + (15, 1, 'shared-dawn', 'Shared Dawn', 3, 3, 2, 12, 5, 'party_heal_percent', 5, '*', 'Increases party-wide healing by 5% per rank. Requires Lingering Rays rank 5.'), + (16, 1, 'miracle-worker', 'Miracle Worker', 1, 4, 2, 15, 3, 'cooldown_reduction_percent', 10, 'S', 'Reduces major healing cooldowns by 10%. Requires Shared Dawn rank 3.'), + + (3, 2, 'deep-roots', 'Deep Roots', 5, 1, 1, NULL, 0, 'max_resource', 2, 'R', 'Increases maximum Bloom by 2 per rank.'), + (20, 2, 'patient-growth', 'Patient Growth', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, 's', 'Increases healing-over-time power by 2% per rank.'), + (21, 2, 'living-bark', 'Living Bark', 5, 1, 3, NULL, 0, 'absorb_power_percent', 2, 'B', 'Strengthens protective effects by 2% per rank.'), + (22, 2, 'spring-rain', 'Spring Rain', 5, 2, 1, 3, 3, 'resource_regen_percent', 2, 'r', 'Improves Bloom regeneration by 2% per rank. Requires Deep Roots rank 3.'), + (23, 2, 'perennial', 'Perennial', 5, 2, 2, 20, 3, 'hot_duration_percent', 4, 'P', 'Extends healing-over-time duration by 4% per rank. Requires Patient Growth rank 3.'), + (24, 2, 'sheltering-canopy', 'Sheltering Canopy', 5, 2, 3, 21, 3, 'party_heal_percent', 2, 'C', 'Improves healing on injured allies by 2% per rank. Requires Living Bark rank 3.'), + (25, 2, 'old-growth', 'Old Growth', 3, 3, 1, 22, 5, 'direct_heal_percent', 5, 'T', 'Strengthens direct healing by 5% per rank. Requires Spring Rain rank 5.'), + (26, 2, 'endless-season', 'Endless Season', 3, 3, 2, 23, 5, 'hot_power_percent', 5, 'E', 'Further improves periodic healing by 5% per rank. Requires Perennial rank 5.'), + (27, 2, 'heart-of-the-grove', 'Heart of the Grove', 1, 4, 2, 26, 3, 'cooldown_reduction_percent', 10, 'H', 'Reduces major healing cooldowns by 10%. Requires Endless Season rank 3.'), + + (4, 3, 'precise-script', 'Precise Script', 5, 1, 1, NULL, 0, 'direct_heal_percent', 2, '+', 'Increases direct healing by 2% per rank.'), + (40, 3, 'deep-focus', 'Deep Focus', 5, 1, 2, NULL, 0, 'max_resource', 2, 'F', 'Increases maximum Focus by 2 per rank.'), + (41, 3, 'reinforced-runes', 'Reinforced Runes', 5, 1, 3, NULL, 0, 'absorb_power_percent', 2, 'O', 'Strengthens barriers by 2% per rank.'), + (42, 3, 'efficient-lines', 'Efficient Lines', 5, 2, 1, 4, 3, 'resource_cost_percent', -2, 'E', 'Reduces ability costs by 2% per rank. Requires Precise Script rank 3.'), + (43, 3, 'resonant-pattern', 'Resonant Pattern', 5, 2, 2, 40, 3, 'party_heal_percent', 2, '*', 'Increases linked party healing by 2% per rank. Requires Deep Focus rank 3.'), + (44, 3, 'layered-aegis', 'Layered Aegis', 5, 2, 3, 41, 3, 'absorb_power_percent', 3, 'A', 'Further improves barriers by 3% per rank. Requires Reinforced Runes rank 3.'), + (45, 3, 'perfect-notation', 'Perfect Notation', 3, 3, 1, 42, 5, 'critical_heal_percent', 2, '!', 'Adds 2% healing critical chance per rank. Requires Efficient Lines rank 5.'), + (46, 3, 'grand-concordance', 'Grand Concordance', 3, 3, 2, 43, 5, 'party_heal_percent', 5, 'G', 'Improves party-wide healing by 5% per rank. Requires Resonant Pattern rank 5.'), + (47, 3, 'masterwork', 'Masterwork', 1, 4, 2, 46, 3, 'cooldown_reduction_percent', 10, 'M', 'Reduces major healing cooldowns by 10%. Requires Grand Concordance rank 3.'); + +UPDATE talents SET + branch = CASE slug + WHEN 'bright-reserves' THEN 1 + WHEN 'gentle-dawn' THEN 2 + WHEN 'deep-roots' THEN 1 + WHEN 'precise-script' THEN 1 + ELSE branch + END, + effect_type = CASE slug + WHEN 'bright-reserves' THEN 'max_resource' + WHEN 'gentle-dawn' THEN 'hot_power_percent' + WHEN 'deep-roots' THEN 'max_resource' + WHEN 'precise-script' THEN 'direct_heal_percent' + ELSE effect_type + END, + effect_value_per_rank = 2, + glyph = CASE slug + WHEN 'bright-reserves' THEN 'M' + WHEN 'gentle-dawn' THEN '~' + WHEN 'deep-roots' THEN 'R' + WHEN 'precise-script' THEN '+' + ELSE glyph + END +WHERE slug IN ('bright-reserves', 'gentle-dawn', 'deep-roots', 'precise-script'); + +UPDATE talents SET description = 'Increases maximum Mana by 2 per rank.' +WHERE slug = 'bright-reserves'; +UPDATE talents SET description = 'Increases healing-over-time power by 2% per rank.' +WHERE slug = 'gentle-dawn'; +UPDATE talents SET description = 'Increases maximum Bloom by 2 per rank.' +WHERE slug = 'deep-roots'; +UPDATE talents SET description = 'Increases direct healing by 2% per rank.' +WHERE slug = 'precise-script'; + +INSERT INTO game_settings (key, value) VALUES + ('max_level', '25'), + ('max_talent_points', '25'), + ('max_ability_slots', '6') +ON CONFLICT(key) DO UPDATE SET value = excluded.value; + +DELETE FROM level_progression WHERE level > 25; + +WITH RECURSIVE levels(level) AS ( + SELECT 1 + UNION ALL + SELECT level + 1 FROM levels WHERE level < 25 +) +INSERT OR IGNORE INTO level_progression (level, experience_required, talent_points_total) +SELECT level, (level - 1) * (level - 1) * 100, level +FROM levels; + +UPDATE characters +SET + level = MIN(level, 25), + experience = MIN( + experience, + (SELECT experience_required FROM level_progression WHERE level = 25) + ), + talent_points = MIN(talent_points, 25); + +UPDATE character_ability_slots +SET spell_id = NULL +WHERE spell_id IN ( + SELECT spells.id + FROM spells + JOIN characters ON characters.id = character_ability_slots.character_id + WHERE spells.unlock_level > characters.level +); + +-- Generated monster-part loot tiers. Dungeon 1 remains authored by hand above. +CREATE TEMP TABLE IF NOT EXISTS generated_loot_tiers ( + item_level INTEGER PRIMARY KEY, + dungeon_id INTEGER NOT NULL, + raid_id INTEGER NOT NULL, + dungeon_difficulty_id INTEGER NOT NULL, + raid_difficulty_id INTEGER NOT NULL, + recipe_base INTEGER NOT NULL, + craft_quantity INTEGER NOT NULL +); + +DELETE FROM generated_loot_tiers; +INSERT INTO generated_loot_tiers + (item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity) +VALUES + (10, 3, 2, 2, 101, 1100, 2), + (15, 4, 5, 3, 103, 1200, 3), + (20, 6, 7, 4, 104, 1300, 4), + (25, 8, 9, 5, 105, 1400, 5); + +CREATE TEMP TABLE IF NOT EXISTS generated_bosses ( + item_level INTEGER NOT NULL, + boss_index INTEGER NOT NULL, + boss_global_index INTEGER NOT NULL, + boss_name TEXT NOT NULL, + boss_slug TEXT NOT NULL, + PRIMARY KEY (item_level, boss_index) +); + +DELETE FROM generated_bosses; +INSERT INTO generated_bosses + (item_level, boss_index, boss_global_index, boss_name, boss_slug) +VALUES + (10, 0, 0, 'Tigrex', 'tigrex'), + (10, 1, 1, 'Rathalos', 'rathalos'), + (10, 2, 2, 'Gypceros', 'gypceros'), + (15, 0, 3, 'Nargacuga', 'nargacuga'), + (15, 1, 4, 'Azuros', 'azuros'), + (15, 2, 5, 'Diablos', 'diablos'), + (20, 0, 6, 'Barroth', 'barroth'), + (20, 1, 7, 'Tobi Kadachi', 'tobi-kadachi'), + (20, 2, 8, 'Monoblos', 'monoblos'), + (25, 0, 9, 'Anjanath', 'anjanath'), + (25, 1, 10, 'Bazelgeuse', 'bazelgeuse'), + (25, 2, 11, 'Odogaron', 'odogaron'); + +CREATE TEMP TABLE IF NOT EXISTS generated_drop_patterns ( + boss_index INTEGER NOT NULL, + drop_index INTEGER NOT NULL, + drop_weight INTEGER NOT NULL, + rarity TEXT NOT NULL, + PRIMARY KEY (boss_index, drop_index) +); + +DELETE FROM generated_drop_patterns; +INSERT INTO generated_drop_patterns + (boss_index, drop_index, drop_weight, rarity) +VALUES + (0, 1, 50, 'uncommon'), + (0, 2, 5, 'rare'), + (0, 3, 15, 'uncommon'), + (0, 4, 30, 'uncommon'), + (1, 1, 32, 'uncommon'), + (1, 2, 50, 'uncommon'), + (1, 3, 13, 'uncommon'), + (1, 4, 5, 'rare'), + (2, 1, 36, 'uncommon'), + (2, 2, 52, 'uncommon'), + (2, 3, 10, 'rare'), + (2, 4, 2, 'epic'); + +INSERT OR IGNORE INTO locations (id, slug, name, description) VALUES + (3, 'monster-frontier', 'The Monster Frontier', 'Hunting grounds used for tiered monster-part progression.'); + +UPDATE dungeons +SET slug = 'tigrex-raid', + name = 'Tigrex Raid', + location_id = 3, + recommended_level = 5, + content_type = 'raid', + party_size = 10, + completion_item_level = NULL, + experience_reward = 275, + description = 'A raid-scale hunt against Tigrex, Rathalos, and Gypceros.' +WHERE id = 2; + +INSERT OR IGNORE INTO dungeons + (id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description) +VALUES + (3, 3, 'tigrex-hunting-ground', 'Tigrex Hunting Ground', 5, 'dungeon', 5, NULL, 205, 'A three-boss hunt featuring Tigrex, Rathalos, and Gypceros.'), + (4, 3, 'nargacuga-hunting-ground', 'Nargacuga Hunting Ground', 10, 'dungeon', 5, NULL, 245, 'A three-boss hunt featuring Nargacuga, Azuros, and Diablos.'), + (5, 3, 'nargacuga-raid', 'Nargacuga Raid', 10, 'raid', 10, NULL, 325, 'A raid-scale hunt against Nargacuga, Azuros, and Diablos.'), + (6, 3, 'barroth-hunting-ground', 'Barroth Hunting Ground', 15, 'dungeon', 5, NULL, 285, 'A three-boss hunt featuring Barroth, Tobi Kadachi, and Monoblos.'), + (7, 3, 'barroth-raid', 'Barroth Raid', 15, 'raid', 10, NULL, 375, 'A raid-scale hunt against Barroth, Tobi Kadachi, and Monoblos.'), + (8, 3, 'anjanath-hunting-ground', 'Anjanath Hunting Ground', 20, 'dungeon', 5, NULL, 325, 'A three-boss hunt featuring Anjanath, Bazelgeuse, and Odogaron.'), + (9, 3, 'anjanath-raid', 'Anjanath Raid', 20, 'raid', 10, NULL, 425, 'A raid-scale hunt against Anjanath, Bazelgeuse, and Odogaron.'); + +UPDATE difficulties +SET dropped_item_level = 10, + unlock_level = 5, + health_multiplier = 1.35, + damage_multiplier = 1.2, + experience_multiplier = 1.75, + description = 'Veteran raid difficulty with extra monster-part drops.' +WHERE id = 101; + +INSERT OR IGNORE INTO difficulties + (id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description) +VALUES + (103, 'raid-champion', 'Champion Raid', 15, 10, 1.7, 1.45, 2.4, 'Champion raid difficulty with extra monster-part drops.'), + (104, 'raid-mythic', 'Mythic Raid', 20, 15, 2.1, 1.75, 3.2, 'Mythic raid difficulty with extra monster-part drops.'), + (105, 'raid-ascendant', 'Ascendant Raid', 25, 20, 2.6, 2.1, 4.2, 'Ascendant raid difficulty with extra monster-part drops.'); + +DELETE FROM dungeon_difficulties WHERE dungeon_id = 2 AND difficulty_id <> 101; + +INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id) +SELECT dungeon_id, dungeon_difficulty_id FROM generated_loot_tiers +UNION ALL +SELECT raid_id, raid_difficulty_id FROM generated_loot_tiers; + +UPDATE encounters +SET slug = CASE id + WHEN 100 THEN 'tigrex-raid-approach' + WHEN 101 THEN 'tigrex-raid-guardians' + WHEN 102 THEN 'tigrex-raid' + WHEN 103 THEN 'rathalos-raid-approach' + WHEN 104 THEN 'rathalos-raid-guardians' + WHEN 105 THEN 'rathalos-raid' + WHEN 106 THEN 'gypceros-raid-approach' + WHEN 107 THEN 'gypceros-raid-guardians' + WHEN 108 THEN 'gypceros-raid' + ELSE slug + END, + name = CASE id + WHEN 100 THEN 'Tigrex Approach' + WHEN 101 THEN 'Tigrex Guardians' + WHEN 102 THEN 'Tigrex' + WHEN 103 THEN 'Rathalos Approach' + WHEN 104 THEN 'Rathalos Guardians' + WHEN 105 THEN 'Rathalos' + WHEN 106 THEN 'Gypceros Approach' + WHEN 107 THEN 'Gypceros Guardians' + WHEN 108 THEN 'Gypceros' + ELSE name + END, + encounter_type = CASE WHEN id IN (102, 105, 108) THEN 'boss' ELSE 'trash' END, + description = CASE id + WHEN 102 THEN 'Tigrex drops monster parts for item level 10 crafting.' + WHEN 105 THEN 'Rathalos drops monster parts for item level 10 crafting.' + WHEN 108 THEN 'Gypceros drops monster parts for item level 10 crafting.' + ELSE 'Hunters clear the raid path.' + END +WHERE id BETWEEN 100 AND 108; + +INSERT OR IGNORE INTO encounters + (id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description) +SELECT + generated_loot_tiers.dungeon_id * 100 + generated_bosses.boss_index * 3 + offset.value, + generated_loot_tiers.dungeon_id, + generated_bosses.boss_index * 3 + offset.value, + generated_bosses.boss_slug || '-dungeon-' || offset.slug, + CASE offset.encounter_type + WHEN 'boss' THEN generated_bosses.boss_name + ELSE generated_bosses.boss_name || ' ' || offset.name + END, + offset.encounter_type, + offset.health + generated_loot_tiers.item_level * 45 + generated_bosses.boss_index * 120, + offset.damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 5, + offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8, + offset.party_damage + generated_bosses.boss_index * 3, + CASE offset.encounter_type + WHEN 'boss' THEN generated_bosses.boss_name || ' drops monster parts for item level ' || generated_loot_tiers.item_level || ' crafting.' + ELSE 'Hunters clear the path before ' || generated_bosses.boss_name || '.' + END +FROM generated_loot_tiers +JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level +JOIN ( + SELECT 1 AS value, 'approach' AS slug, 'Approach' AS name, 'trash' AS encounter_type, 650 AS health, 15 AS damage, 9 AS tank_damage, 28 AS party_damage + UNION ALL SELECT 2, 'guardians', 'Guardians', 'trash', 720, 16, 10, 30 + UNION ALL SELECT 3, 'boss', '', 'boss', 980, 20, 14, 34 +) AS offset; + +INSERT OR IGNORE INTO encounters + (id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description) +SELECT + generated_loot_tiers.raid_id * 100 + generated_bosses.boss_index * 3 + offset.value, + generated_loot_tiers.raid_id, + generated_bosses.boss_index * 3 + offset.value, + generated_bosses.boss_slug || '-raid-' || offset.slug, + CASE offset.encounter_type + WHEN 'boss' THEN generated_bosses.boss_name + ELSE generated_bosses.boss_name || ' ' || offset.name + END, + offset.encounter_type, + offset.health + generated_loot_tiers.item_level * 45 + generated_bosses.boss_index * 120 + 900, + offset.damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 5, + offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8, + offset.party_damage + generated_bosses.boss_index * 3 + 24, + CASE offset.encounter_type + WHEN 'boss' THEN generated_bosses.boss_name || ' drops monster parts for item level ' || generated_loot_tiers.item_level || ' crafting.' + ELSE 'Hunters clear the raid path before ' || generated_bosses.boss_name || '.' + END +FROM generated_loot_tiers +JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level +JOIN ( + SELECT 1 AS value, 'approach' AS slug, 'Approach' AS name, 'trash' AS encounter_type, 650 AS health, 15 AS damage, 9 AS tank_damage, 28 AS party_damage + UNION ALL SELECT 2, 'guardians', 'Guardians', 'trash', 720, 16, 10, 30 + UNION ALL SELECT 3, 'boss', '', 'boss', 980, 20, 14, 34 +) AS offset +WHERE generated_loot_tiers.raid_id <> 2; + +INSERT OR IGNORE INTO items + (id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description) +SELECT + 3000 + generated_loot_tiers.item_level * 10 + generated_bosses.boss_global_index * 10 + generated_drop_patterns.drop_index, + generated_bosses.boss_slug || '-drop-' || generated_drop_patterns.drop_index || '-ilvl-' || generated_loot_tiers.item_level, + generated_bosses.boss_name || ' Drop ' || generated_drop_patterns.drop_index, + 'component', + generated_drop_patterns.rarity, + generated_loot_tiers.item_level, + 0, + 0, + CAST(generated_drop_patterns.drop_index AS TEXT), + 'A monster part from ' || generated_bosses.boss_name || '.' +FROM generated_loot_tiers +JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level +JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index; + +DELETE FROM encounter_loot WHERE encounter_id NOT IN (3, 12, 22); +DELETE FROM encounter_loot WHERE item_id IN (SELECT id FROM items WHERE slot <> 'component'); + +INSERT OR IGNORE INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) +SELECT + generated_loot_tiers.dungeon_id * 100 + generated_bosses.boss_index * 3 + 3, + 3000 + generated_loot_tiers.item_level * 10 + generated_bosses.boss_global_index * 10 + generated_drop_patterns.drop_index, + generated_loot_tiers.dungeon_difficulty_id, + generated_drop_patterns.drop_weight, + 1.0 +FROM generated_loot_tiers +JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level +JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index; + +INSERT OR IGNORE INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) +SELECT + CASE generated_loot_tiers.raid_id + WHEN 2 THEN 102 + generated_bosses.boss_index * 3 + ELSE generated_loot_tiers.raid_id * 100 + generated_bosses.boss_index * 3 + 3 + END, + 3000 + generated_loot_tiers.item_level * 10 + generated_bosses.boss_global_index * 10 + generated_drop_patterns.drop_index, + generated_loot_tiers.raid_difficulty_id, + generated_drop_patterns.drop_weight, + 1.0 +FROM generated_loot_tiers +JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level +JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index; + +CREATE TEMP TABLE IF NOT EXISTS generated_recipe_offsets ( + recipe_offset INTEGER PRIMARY KEY, + boss_index INTEGER NOT NULL +); + +DELETE FROM generated_recipe_offsets; +INSERT INTO generated_recipe_offsets (recipe_offset, boss_index) VALUES + (1, 0), (2, 0), (3, 0), + (4, 1), (5, 1), (6, 1), + (7, 2), (8, 2), (9, 2); + +UPDATE crafting_recipes +SET source_dungeon_id = ( + SELECT generated_loot_tiers.dungeon_id + FROM generated_loot_tiers + WHERE crafting_recipes.id BETWEEN generated_loot_tiers.recipe_base + 1 AND generated_loot_tiers.recipe_base + 9 + ), + source_encounter_id = ( + SELECT generated_loot_tiers.dungeon_id * 100 + generated_recipe_offsets.boss_index * 3 + 3 + FROM generated_loot_tiers + JOIN generated_recipe_offsets + ON crafting_recipes.id = generated_loot_tiers.recipe_base + generated_recipe_offsets.recipe_offset + ) +WHERE id BETWEEN 1101 AND 1409; + +UPDATE crafting_recipes +SET source_dungeon_id = 2, + source_encounter_id = 102 + ( + SELECT generated_recipe_offsets.boss_index * 3 + FROM generated_recipe_offsets + WHERE crafting_recipes.id = 2000 + generated_recipe_offsets.recipe_offset + ) +WHERE id BETWEEN 2001 AND 2009; + +DELETE FROM crafting_recipe_components +WHERE recipe_id BETWEEN 1101 AND 1409 + OR recipe_id BETWEEN 2001 AND 2009; + +INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity) +SELECT + generated_loot_tiers.recipe_base + generated_recipe_offsets.recipe_offset, + 3000 + generated_loot_tiers.item_level * 10 + generated_bosses.boss_global_index * 10 + component.drop_index, + CASE component.drop_index + WHEN 1 THEN 5 + WHEN 2 THEN 3 + WHEN 4 THEN 1 + END +FROM generated_loot_tiers +JOIN generated_recipe_offsets +JOIN generated_bosses + ON generated_bosses.item_level = generated_loot_tiers.item_level + AND generated_bosses.boss_index = generated_recipe_offsets.boss_index +JOIN ( + SELECT 1 AS drop_index + UNION ALL SELECT 2 + UNION ALL SELECT 4 +) AS component; + +INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity) +SELECT + 2000 + generated_recipe_offsets.recipe_offset, + 3000 + generated_loot_tiers.item_level * 10 + generated_bosses.boss_global_index * 10 + component.drop_index, + CASE component.drop_index + WHEN 1 THEN 5 + WHEN 2 THEN 3 + WHEN 4 THEN 1 + END +FROM generated_loot_tiers +JOIN generated_recipe_offsets +JOIN generated_bosses + ON generated_bosses.item_level = generated_loot_tiers.item_level + AND generated_bosses.boss_index = generated_recipe_offsets.boss_index +JOIN ( + SELECT 1 AS drop_index + UNION ALL SELECT 2 + UNION ALL SELECT 4 +) AS component +WHERE generated_loot_tiers.item_level = 10; + +UPDATE items +SET name = CASE id + WHEN 5 THEN 'Bulldrome Helmet' + WHEN 2 THEN 'Bulldrome Chest' + WHEN 6 THEN 'Bulldrome Gloves' + WHEN 4 THEN 'Yian Kut-Ku Boots' + WHEN 1 THEN 'Yian Kut-Ku Ring' + WHEN 7 THEN 'Yian Kut-Ku Trinket' + WHEN 3 THEN 'Rathian Weapon' + WHEN 8 THEN 'Rathian Pants' + WHEN 9 THEN 'Rathian Necklace' + WHEN 205 THEN 'Tempered Tigrex Helmet' + WHEN 202 THEN 'Tempered Tigrex Chest' + WHEN 206 THEN 'Tempered Tigrex Gloves' + WHEN 204 THEN 'Tempered Rathalos Boots' + WHEN 201 THEN 'Tempered Rathalos Ring' + WHEN 207 THEN 'Tempered Rathalos Trinket' + WHEN 203 THEN 'Tempered Gypceros Weapon' + WHEN 208 THEN 'Tempered Gypceros Pants' + WHEN 209 THEN 'Tempered Gypceros Necklace' + WHEN 305 THEN 'Runed Nargacuga Helmet' + WHEN 302 THEN 'Runed Nargacuga Chest' + WHEN 306 THEN 'Runed Nargacuga Gloves' + WHEN 304 THEN 'Runed Azuros Boots' + WHEN 301 THEN 'Runed Azuros Ring' + WHEN 307 THEN 'Runed Azuros Trinket' + WHEN 303 THEN 'Runed Diablos Weapon' + WHEN 308 THEN 'Runed Diablos Pants' + WHEN 309 THEN 'Runed Diablos Necklace' + WHEN 405 THEN 'Mythic Barroth Helmet' + WHEN 402 THEN 'Mythic Barroth Chest' + WHEN 406 THEN 'Mythic Barroth Gloves' + WHEN 404 THEN 'Mythic Tobi Kadachi Boots' + WHEN 401 THEN 'Mythic Tobi Kadachi Ring' + WHEN 407 THEN 'Mythic Tobi Kadachi Trinket' + WHEN 403 THEN 'Mythic Monoblos Weapon' + WHEN 408 THEN 'Mythic Monoblos Pants' + WHEN 409 THEN 'Mythic Monoblos Necklace' + WHEN 505 THEN 'Ascendant Anjanath Helmet' + WHEN 502 THEN 'Ascendant Anjanath Chest' + WHEN 506 THEN 'Ascendant Anjanath Gloves' + WHEN 504 THEN 'Ascendant Bazelgeuse Boots' + WHEN 501 THEN 'Ascendant Bazelgeuse Ring' + WHEN 507 THEN 'Ascendant Bazelgeuse Trinket' + WHEN 503 THEN 'Ascendant Odogaron Weapon' + WHEN 508 THEN 'Ascendant Odogaron Pants' + WHEN 509 THEN 'Ascendant Odogaron Necklace' + WHEN 714 THEN 'Raid Tigrex Helmet' + WHEN 711 THEN 'Raid Tigrex Chest' + WHEN 715 THEN 'Raid Tigrex Gloves' + WHEN 713 THEN 'Raid Rathalos Boots' + WHEN 710 THEN 'Raid Rathalos Ring' + WHEN 716 THEN 'Raid Rathalos Trinket' + WHEN 712 THEN 'Raid Gypceros Weapon' + WHEN 717 THEN 'Raid Gypceros Pants' + WHEN 718 THEN 'Raid Gypceros Necklace' + ELSE name + END, + description = CASE id + WHEN 5 THEN 'Crafted from Bulldrome monster parts.' + WHEN 2 THEN 'Crafted from Bulldrome monster parts.' + WHEN 6 THEN 'Crafted from Bulldrome monster parts.' + WHEN 4 THEN 'Crafted from Yian Kut-Ku monster parts.' + WHEN 1 THEN 'Crafted from Yian Kut-Ku monster parts.' + WHEN 7 THEN 'Crafted from Yian Kut-Ku monster parts.' + WHEN 3 THEN 'Crafted from Rathian monster parts.' + WHEN 8 THEN 'Crafted from Rathian monster parts.' + WHEN 9 THEN 'Crafted from Rathian monster parts.' + ELSE description + END +WHERE id IN ( + 1, 2, 3, 4, 5, 6, 7, 8, 9, + 201, 202, 203, 204, 205, 206, 207, 208, 209, + 301, 302, 303, 304, 305, 306, 307, 308, 309, + 401, 402, 403, 404, 405, 406, 407, 408, 409, + 501, 502, 503, 504, 505, 506, 507, 508, 509, + 710, 711, 712, 713, 714, 715, 716, 717, 718 +); + +UPDATE items +SET slug = CASE id + WHEN 860 THEN 'bulldrome-drop-1' + WHEN 861 THEN 'bulldrome-drop-2' + WHEN 862 THEN 'bulldrome-drop-3' + WHEN 863 THEN 'bulldrome-drop-4' + WHEN 864 THEN 'yian-kut-ku-drop-1' + WHEN 865 THEN 'yian-kut-ku-drop-2' + WHEN 866 THEN 'yian-kut-ku-drop-3' + WHEN 867 THEN 'yian-kut-ku-drop-4' + WHEN 868 THEN 'rathian-drop-1' + WHEN 869 THEN 'rathian-drop-2' + WHEN 870 THEN 'rathian-drop-3' + WHEN 871 THEN 'rathian-drop-4' + ELSE slug + END, + name = CASE id + WHEN 860 THEN 'Bulldrome Drop 1' + WHEN 861 THEN 'Bulldrome Drop 2' + WHEN 862 THEN 'Bulldrome Drop 3' + WHEN 863 THEN 'Bulldrome Drop 4' + WHEN 864 THEN 'Yian Kut-Ku Drop 1' + WHEN 865 THEN 'Yian Kut-Ku Drop 2' + WHEN 866 THEN 'Yian Kut-Ku Drop 3' + WHEN 867 THEN 'Yian Kut-Ku Drop 4' + WHEN 868 THEN 'Rathian Drop 1' + WHEN 869 THEN 'Rathian Drop 2' + WHEN 870 THEN 'Rathian Drop 3' + WHEN 871 THEN 'Rathian Drop 4' + ELSE name + END, + glyph = CASE id + WHEN 860 THEN '1' + WHEN 861 THEN '2' + WHEN 862 THEN '3' + WHEN 863 THEN '4' + WHEN 864 THEN '1' + WHEN 865 THEN '2' + WHEN 866 THEN '3' + WHEN 867 THEN '4' + WHEN 868 THEN '1' + WHEN 869 THEN '2' + WHEN 870 THEN '3' + WHEN 871 THEN '4' + ELSE glyph + END, + description = CASE + WHEN id BETWEEN 860 AND 863 THEN 'A monster part from Bulldrome.' + WHEN id BETWEEN 864 AND 867 THEN 'A monster part from Yian Kut-Ku.' + WHEN id BETWEEN 868 AND 871 THEN 'A monster part from Rathian.' + ELSE description + END +WHERE id BETWEEN 860 AND 871; + +DELETE FROM crafting_recipe_components WHERE recipe_id BETWEEN 1001 AND 1009; + +INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES + (1001, 860, 5), (1001, 861, 3), (1001, 863, 1), + (1002, 860, 5), (1002, 861, 3), (1002, 863, 1), + (1003, 860, 5), (1003, 861, 3), (1003, 863, 1), + (1004, 864, 5), (1004, 865, 3), (1004, 867, 1), + (1005, 864, 5), (1005, 865, 3), (1005, 867, 1), + (1006, 864, 5), (1006, 865, 3), (1006, 867, 1), + (1007, 868, 5), (1007, 869, 3), (1007, 871, 1), + (1008, 868, 5), (1008, 869, 3), (1008, 871, 1), + (1009, 868, 5), (1009, 869, 3), (1009, 871, 1); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c4eb64b --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist', 'android/app/build', 'android/build']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..6cb642e --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + testgame + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..57ef71f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3730 @@ +{ + "name": "ashen-halls", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ashen-halls", + "version": "0.0.0", + "dependencies": { + "@capacitor/android": "^8.4.0", + "@capacitor/core": "^8.4.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@capacitor/cli": "^8.4.0", + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capacitor/android": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.4.0.tgz", + "integrity": "sha512-K1ZPkQzvRzPEALz9nBdLx5p5nAPzp5fsTYWk7LRiKZeH/NXqjDvqfTv7lrLgrziQNoDeaL6ijg64oBREzXiV+g==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.4.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.4.0.tgz", + "integrity": "sha512-5Z9RKHxiqJYRTLrfMeZmzR4qrlg5B85MxsWZ5goyXsLkO3bgpW9a1qV/6fR1SX9s5gwLza5y7PZVwITl/hDJ7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/cli/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@capacitor/core": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.4.0.tgz", + "integrity": "sha512-LrS1xPIrqLtJABBIPDGXxxKmI9OyesrzWw8DiHbxhSC9JoiLUleUAJlX1a0LWIVLRbuY4Szgf9huFeRqYH2SAQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "dev": true, + "license": "ISC" + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", + "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..94cdb0d --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "ashen-halls", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "predev": "npm run db:init", + "dev": "vite", + "build": "npx tsc -b --noEmit && npx vite build && node scripts/generate-service-worker.mjs", + "android:sync": "npm run build && cap sync android", + "android:open": "cap open android", + "android:apk": "npm run android:sync && cd android && ./gradlew assembleDebug", + "accounts:ip": "node scripts/manage-ip-allowance.mjs", + "db:backup": "node scripts/backup-db.mjs", + "db:init": "node scripts/init-db.mjs", + "offline:export": "node scripts/export-offline-profile.mjs", + "lint": "eslint .", + "admin:start": "node server/admin.mjs", + "start": "node server/production.mjs", + "prepreview": "npm run db:init", + "preview": "vite preview" + }, + "dependencies": { + "@capacitor/android": "^8.4.0", + "@capacitor/core": "^8.4.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@capacitor/cli": "^8.4.0", + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/public/boss-placeholder.svg b/public/boss-placeholder.svg new file mode 100644 index 0000000..10a685e --- /dev/null +++ b/public/boss-placeholder.svg @@ -0,0 +1,10 @@ + + Boss placeholder + A placeholder boss icon with a monster silhouette. + + + + + + + diff --git a/public/equipment-placeholder.svg b/public/equipment-placeholder.svg new file mode 100644 index 0000000..faff5ac --- /dev/null +++ b/public/equipment-placeholder.svg @@ -0,0 +1,9 @@ + + Equipment placeholder + A placeholder equipment icon with an item silhouette. + + + + + + diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/backup-db.mjs b/scripts/backup-db.mjs new file mode 100644 index 0000000..784b5fe --- /dev/null +++ b/scripts/backup-db.mjs @@ -0,0 +1,20 @@ +import { mkdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +const sourcePath = resolve('data/game.db') +const backupDirectory = resolve('backups') +const timestamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-') +const backupPath = resolve(backupDirectory, `game-${timestamp}.db`) + +mkdirSync(backupDirectory, { recursive: true }) + +const database = new DatabaseSync(sourcePath) + +try { + const escapedPath = backupPath.replaceAll("'", "''") + database.exec(`VACUUM INTO '${escapedPath}'`) + console.log(`SQLite backup created: ${backupPath}`) +} finally { + database.close() +} diff --git a/scripts/export-offline-profile.mjs b/scripts/export-offline-profile.mjs new file mode 100644 index 0000000..8ca48ef --- /dev/null +++ b/scripts/export-offline-profile.mjs @@ -0,0 +1,18 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { getProfile } from '../server/game-api.mjs' + +const database = new DatabaseSync(':memory:') + +try { + database.exec(readFileSync('db/schema.sql', 'utf8')) + database.exec(readFileSync('db/seed.sql', 'utf8')) + const profile = getProfile(database, 1) + writeFileSync( + 'src/offline-starter-profile.json', + `${JSON.stringify(profile, null, 2)}\n`, + ) + console.log('Offline starter profile exported from SQLite.') +} finally { + database.close() +} diff --git a/scripts/generate-service-worker.mjs b/scripts/generate-service-worker.mjs new file mode 100644 index 0000000..2dc03a5 --- /dev/null +++ b/scripts/generate-service-worker.mjs @@ -0,0 +1,63 @@ +import { readdirSync, writeFileSync } from 'node:fs' +import { relative, resolve } from 'node:path' + +const distDirectory = resolve('dist') + +function listFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = resolve(directory, entry.name) + return entry.isDirectory() ? listFiles(path) : [path] + }) +} + +const assets = listFiles(distDirectory) + .map((path) => `/${relative(distDirectory, path).replaceAll('\\', '/')}`) + .filter((path) => path !== '/service-worker.js') + .sort() +const cacheName = `chronicle-${Date.now()}` +const source = `const CACHE_NAME = ${JSON.stringify(cacheName)} +const APP_ASSETS = ${JSON.stringify(assets, null, 2)} + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(APP_ASSETS)) + .then(() => self.skipWaiting()), + ) +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all( + keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)), + )) + .then(() => self.clients.claim()), + ) +}) + +self.addEventListener('fetch', (event) => { + const requestUrl = new URL(event.request.url) + if ( + event.request.method !== 'GET' + || requestUrl.origin !== self.location.origin + || requestUrl.pathname.startsWith('/api/') + ) { + return + } + + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached + return fetch(event.request).catch(() => ( + event.request.mode === 'navigate' + ? caches.match('/index.html') + : Response.error() + )) + }), + ) +}) +` + +writeFileSync(resolve(distDirectory, 'service-worker.js'), source) +console.log(`Offline app shell generated with ${assets.length} files.`) diff --git a/scripts/init-db.mjs b/scripts/init-db.mjs new file mode 100644 index 0000000..a904261 --- /dev/null +++ b/scripts/init-db.mjs @@ -0,0 +1,262 @@ +import { mkdirSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { DatabaseSync } from 'node:sqlite' + +mkdirSync('data', { recursive: true }) + +const database = new DatabaseSync('data/game.db') +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') + +database.exec(schema) + +database.exec(` + CREATE TABLE IF NOT EXISTS crafting_recipes ( + id INTEGER PRIMARY KEY, + item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE, + difficulty_id INTEGER REFERENCES difficulties(id), + source_dungeon_id INTEGER REFERENCES dungeons(id) ON DELETE CASCADE, + source_encounter_id INTEGER REFERENCES encounters(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS crafting_recipe_components ( + recipe_id INTEGER NOT NULL REFERENCES crafting_recipes(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL CHECK (quantity > 0), + PRIMARY KEY (recipe_id, item_id) + ); + + CREATE TABLE IF NOT EXISTS item_sets ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS item_set_items ( + set_id INTEGER NOT NULL REFERENCES item_sets(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE, + PRIMARY KEY (set_id, item_id) + ); + + CREATE TABLE IF NOT EXISTS item_set_bonuses ( + id INTEGER PRIMARY KEY, + set_id INTEGER NOT NULL REFERENCES item_sets(id) ON DELETE CASCADE, + required_pieces INTEGER NOT NULL CHECK (required_pieces > 0), + effect_type TEXT NOT NULL, + description TEXT NOT NULL, + UNIQUE (set_id, required_pieces) + ); + + CREATE TABLE IF NOT EXISTS encounter_loot_roll_items ( + roll_id INTEGER NOT NULL REFERENCES encounter_loot_rolls(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id), + quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0), + was_duplicate INTEGER NOT NULL DEFAULT 0 CHECK (was_duplicate IN (0, 1)), + quantity_after INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (roll_id, item_id) + ); +`) + +function addColumnIfMissing(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 migrateCharacterAccountConstraint() { + const tableSql = database.prepare(` + SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'characters' + `).get()?.sql ?? '' + const hasLegacyAccountUnique = /account_id\s+INTEGER\s+UNIQUE/i.test(tableSql) + if (!hasLegacyAccountUnique) return + + const columns = database.prepare('PRAGMA table_info(characters)').all() + const hasCompletedDungeonParts = columns.some( + (candidate) => candidate.name === 'completed_dungeon_parts', + ) + + database.exec('PRAGMA foreign_keys = OFF') + database.exec('BEGIN') + try { + database.exec(` + CREATE TABLE characters_migrated ( + 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, + talent_points INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (account_id, class_id) + ) + `) + database.exec(` + INSERT INTO characters_migrated + (id, account_id, class_id, name, level, experience, talent_points, created_at) + SELECT + id, account_id, class_id, name, level, experience, talent_points, created_at + FROM characters + `) + if (hasCompletedDungeonParts) { + database.exec(` + UPDATE accounts + SET completed_dungeon_parts = MAX( + completed_dungeon_parts, + COALESCE(( + SELECT MAX(completed_dungeon_parts) + FROM characters + WHERE characters.account_id = accounts.id + ), 0) + ) + `) + } + database.exec('DROP TABLE characters') + database.exec('ALTER TABLE characters_migrated RENAME TO characters') + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } finally { + database.exec('PRAGMA foreign_keys = ON') + } +} + +function migrateItemSlotConstraint() { + const tableSql = database.prepare(` + SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'items' + `).get()?.sql ?? '' + if (tableSql.includes("'pants'") && tableSql.includes("'necklace'")) return + + database.exec('PRAGMA foreign_keys = OFF') + database.exec('BEGIN') + try { + database.exec(` + CREATE TABLE items_migrated ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + slot TEXT NOT NULL CHECK (slot IN ('weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket', 'component')), + rarity TEXT NOT NULL, + item_level INTEGER NOT NULL, + healing_power INTEGER NOT NULL DEFAULT 0, + max_resource_bonus INTEGER NOT NULL DEFAULT 0, + glyph TEXT NOT NULL DEFAULT '?', + image_url TEXT NOT NULL DEFAULT '/equipment-placeholder.svg', + description TEXT NOT NULL + ) + `) + database.exec(` + INSERT INTO items_migrated + (id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, image_url, description) + SELECT id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, image_url, description + FROM items + `) + database.exec('DROP TABLE items') + database.exec('ALTER TABLE items_migrated RENAME TO items') + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } finally { + database.exec('PRAGMA foreign_keys = ON') + } +} + +function migrateEncounterLootPrimaryKey() { + const tableSql = database.prepare(` + SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'encounter_loot' + `).get()?.sql ?? '' + if (/PRIMARY KEY\s*\(\s*encounter_id\s*,\s*difficulty_id\s*,\s*item_id\s*\)/i.test(tableSql)) return + + database.exec('PRAGMA foreign_keys = OFF') + database.exec('BEGIN') + try { + database.exec(` + CREATE TABLE encounter_loot_migrated ( + encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + difficulty_id INTEGER REFERENCES difficulties(id), + drop_weight INTEGER NOT NULL DEFAULT 100, + drop_chance REAL NOT NULL DEFAULT 0.65 CHECK (drop_chance BETWEEN 0 AND 1), + PRIMARY KEY (encounter_id, difficulty_id, item_id) + ) + `) + database.exec(` + INSERT OR IGNORE INTO encounter_loot_migrated + (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) + SELECT encounter_id, item_id, difficulty_id, drop_weight, drop_chance + FROM encounter_loot + `) + database.exec('DROP TABLE encounter_loot') + database.exec('ALTER TABLE encounter_loot_migrated RENAME TO encounter_loot') + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } finally { + database.exec('PRAGMA foreign_keys = ON') + } +} + +addColumnIfMissing('dungeons', 'content_type', "TEXT NOT NULL DEFAULT 'dungeon'") +addColumnIfMissing('dungeons', 'party_size', 'INTEGER NOT NULL DEFAULT 5') +addColumnIfMissing('dungeons', 'completion_item_level', 'INTEGER') +addColumnIfMissing('dungeons', 'experience_reward', 'INTEGER NOT NULL DEFAULT 100') +addColumnIfMissing('classes', 'theme_color', "TEXT NOT NULL DEFAULT '#e5b95f'") +addColumnIfMissing('spells', 'unlock_level', 'INTEGER NOT NULL DEFAULT 1') +addColumnIfMissing('spells', 'glyph', "TEXT NOT NULL DEFAULT '+'") +addColumnIfMissing('encounter_loot', 'difficulty_id', 'INTEGER REFERENCES difficulties(id)') +addColumnIfMissing('characters', 'talent_points', 'INTEGER NOT NULL DEFAULT 1') +addColumnIfMissing('characters', 'account_id', 'INTEGER REFERENCES accounts(id)') +addColumnIfMissing('characters', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0') +addColumnIfMissing('talents', 'branch', 'INTEGER NOT NULL DEFAULT 1') +addColumnIfMissing('talents', 'prerequisite_talent_id', 'INTEGER REFERENCES talents(id)') +addColumnIfMissing('talents', 'prerequisite_rank', 'INTEGER NOT NULL DEFAULT 0') +addColumnIfMissing('talents', 'effect_type', "TEXT NOT NULL DEFAULT 'placeholder'") +addColumnIfMissing('talents', 'effect_value_per_rank', 'REAL NOT NULL DEFAULT 0') +addColumnIfMissing('talents', 'glyph', "TEXT NOT NULL DEFAULT '+'") +addColumnIfMissing('items', 'glyph', "TEXT NOT NULL DEFAULT '?'") +addColumnIfMissing('items', 'image_url', "TEXT NOT NULL DEFAULT '/equipment-placeholder.svg'") +addColumnIfMissing('difficulties', 'unlock_level', 'INTEGER NOT NULL DEFAULT 1') +addColumnIfMissing('difficulties', 'health_multiplier', 'REAL NOT NULL DEFAULT 1') +addColumnIfMissing('difficulties', 'damage_multiplier', 'REAL NOT NULL DEFAULT 1') +addColumnIfMissing('difficulties', 'experience_multiplier', 'REAL NOT NULL DEFAULT 1') +addColumnIfMissing('dungeon_runs', 'difficulty_id', 'INTEGER REFERENCES difficulties(id)') +addColumnIfMissing('dungeon_runs', 'character_name', "TEXT NOT NULL DEFAULT ''") +addColumnIfMissing('dungeon_runs', 'class_name', "TEXT NOT NULL DEFAULT ''") +addColumnIfMissing('dungeon_runs', 'character_level', 'INTEGER NOT NULL DEFAULT 1') +addColumnIfMissing('dungeon_runs', 'average_item_level', 'REAL NOT NULL DEFAULT 1') +addColumnIfMissing('dungeon_runs', 'resource_spent', 'INTEGER NOT NULL DEFAULT 0') +addColumnIfMissing('dungeon_runs', 'duration_seconds', 'INTEGER NOT NULL DEFAULT 0') +addColumnIfMissing('dungeon_runs', 'leaderboard_eligible', 'INTEGER NOT NULL DEFAULT 0') +addColumnIfMissing('dungeon_runs', 'start_part', 'INTEGER NOT NULL DEFAULT 1') +addColumnIfMissing('dungeon_runs', 'completed_parts', 'INTEGER NOT NULL DEFAULT 1') +addColumnIfMissing('encounter_loot', 'drop_chance', 'REAL NOT NULL DEFAULT 0.65') +addColumnIfMissing('encounters', 'image_url', "TEXT NOT NULL DEFAULT '/boss-placeholder.svg'") + +addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0') +addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0') +addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)') + +migrateCharacterAccountConstraint() +migrateItemSlotConstraint() +migrateEncounterLootPrimaryKey() + +addColumnIfMissing('items', 'image_url', "TEXT NOT NULL DEFAULT '/equipment-placeholder.svg'") + +database.exec(seed) + +const counts = database + .prepare(` + SELECT + (SELECT COUNT(*) FROM classes) AS classes, + (SELECT COUNT(*) FROM encounters) AS encounters, + (SELECT COUNT(*) FROM items) AS items + `) + .get() + +database.close() +console.log(`Database ready: ${counts.classes} class, ${counts.encounters} encounters, ${counts.items} items.`) diff --git a/scripts/manage-ip-allowance.mjs b/scripts/manage-ip-allowance.mjs new file mode 100644 index 0000000..9cc9ad3 --- /dev/null +++ b/scripts/manage-ip-allowance.mjs @@ -0,0 +1,78 @@ +import { isIP } from 'node:net' +import { DatabaseSync } from 'node:sqlite' + +const [command, rawIp, rawLimit, ...noteParts] = process.argv.slice(2) +const database = new DatabaseSync('data/game.db') + +function normalizeIp(value) { + const address = String(value ?? '').trim() + if (address.startsWith('::ffff:') && isIP(address.slice(7)) === 4) { + return address.slice(7) + } + return address +} + +function requireIp(value) { + const address = normalizeIp(value) + if (!isIP(address)) { + throw new Error('Provide a valid IPv4 or IPv6 address.') + } + return address +} + +try { + if (command === 'set') { + const ip = requireIp(rawIp) + const maxAccounts = Number(rawLimit) + if (!Number.isInteger(maxAccounts) || maxAccounts < 2 || maxAccounts > 100) { + throw new Error('The allowed account count must be an integer from 2 to 100.') + } + const note = noteParts.join(' ').slice(0, 200) + database.prepare(` + INSERT INTO account_ip_allowances + (ip_address, max_accounts, note, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(ip_address) DO UPDATE SET + max_accounts = excluded.max_accounts, + note = excluded.note, + updated_at = CURRENT_TIMESTAMP + `).run(ip, maxAccounts, note) + console.log(`${ip} may create up to ${maxAccounts} accounts${note ? ` (${note})` : ''}.`) + } else if (command === 'remove') { + const ip = requireIp(rawIp) + const result = database.prepare(` + DELETE FROM account_ip_allowances WHERE ip_address = ? + `).run(ip) + console.log( + result.changes + ? `${ip} returned to the default one-account limit.` + : `${ip} had no custom allowance.`, + ) + } else if (command === 'list') { + const rows = database.prepare(` + SELECT + account_ip_allowances.ip_address AS ip, + account_ip_allowances.max_accounts AS maxAccounts, + account_ip_allowances.note, + account_ip_allowances.updated_at AS updatedAt, + COUNT(accounts.id) AS existingAccounts + FROM account_ip_allowances + LEFT JOIN accounts + ON accounts.created_ip = account_ip_allowances.ip_address + GROUP BY account_ip_allowances.ip_address + ORDER BY account_ip_allowances.updated_at DESC + `).all() + console.table(rows) + } else { + console.log('Usage:') + console.log(' npm run accounts:ip -- set [note]') + console.log(' npm run accounts:ip -- remove ') + console.log(' npm run accounts:ip -- list') + process.exitCode = command ? 1 : 0 + } +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +} finally { + database.close() +} diff --git a/server/admin.mjs b/server/admin.mjs new file mode 100644 index 0000000..07946d7 --- /dev/null +++ b/server/admin.mjs @@ -0,0 +1,333 @@ +import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { randomBytes } from 'node:crypto' +import { extname, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import { DatabaseSync } from 'node:sqlite' + +const host = '127.0.0.1' +const port = Number(process.env.ADMIN_PORT ?? 4174) +const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url)) +const distPath = fileURLToPath(new URL('../dist', 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 bossImageContentTypes = { + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', +} + +function sendJson(response, status, body) { + response.statusCode = status + response.setHeader('Content-Type', 'application/json') + response.end(JSON.stringify(body)) +} + +async function readJson(request, maxSize = 16 * 1024) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.length + if (size > maxSize) { + const error = new Error('Request body is too large.') + error.status = 413 + throw error + } + chunks.push(chunk) + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function sendBossImage(request, response) { + const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname) + const filename = pathname.replace('/api/boss-images/', '') + if (!/^[A-Za-z0-9._-]+$/.test(filename)) { + sendJson(response, 404, { error: 'Image not found.' }) + return + } + const imagePath = resolve(bossImageDirectory, filename) + const insideDirectory = imagePath.startsWith(resolve(bossImageDirectory) + 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 sendItemImage(request, response) { + const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname) + const filename = pathname.replace('/api/item-images/', '') + if (!/^[A-Za-z0-9._-]+$/.test(filename)) { + sendJson(response, 404, { error: 'Image not found.' }) + return + } + const imagePath = resolve(itemImageDirectory, filename) + const insideDirectory = imagePath.startsWith(resolve(itemImageDirectory) + 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) { + const encounter = database.prepare(` + SELECT id, slug, encounter_type AS encounterType + FROM encounters WHERE id = ? + `).get(encounterId) + if (!encounter || encounter.encounterType !== 'boss') { + throw new Error('Boss encounter 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('Boss image must be 1 byte to 4 MB.') + } + mkdirSync(bossImageDirectory, { recursive: true }) + const filename = `${encounter.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}` + writeFileSync(resolve(bossImageDirectory, filename), bytes, { mode: 0o644 }) + const imageUrl = `/api/boss-images/${filename}` + database.prepare(`UPDATE encounters SET image_url = ? WHERE id = ?`).run(imageUrl, encounterId) + return imageUrl +} + +function saveItemImage(database, itemId, payload) { + const item = database.prepare(`SELECT id, slug, slot FROM items WHERE id = ?`).get(itemId) + if (!item || item.slot === 'component') throw new Error('Equipment item 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('Equipment image must be 1 byte to 4 MB.') + } + mkdirSync(itemImageDirectory, { recursive: true }) + const filename = `${item.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}` + writeFileSync(resolve(itemImageDirectory, filename), bytes, { mode: 0o644 }) + const imageUrl = `/api/item-images/${filename}` + database.prepare(`UPDATE items SET image_url = ? WHERE id = ?`).run(imageUrl, itemId) + return imageUrl +} + +function sendFile(response, filePath) { + const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + } + response.statusCode = 200 + response.setHeader('Content-Type', contentTypes[extname(filePath).toLowerCase()] ?? 'application/octet-stream') + response.setHeader('X-Content-Type-Options', 'nosniff') + response.setHeader('Referrer-Policy', 'same-origin') + response.setHeader('X-Frame-Options', 'DENY') + createReadStream(filePath).pipe(response) +} + +function serveStatic(request, response) { + const requestPath = decodeURIComponent(new URL(request.url, 'http://localhost').pathname) + const candidate = resolve(distPath, `.${requestPath}`) + const insideDist = candidate === distPath || candidate.startsWith(`${distPath}${sep}`) + if (insideDist && existsSync(candidate) && statSync(candidate).isFile()) { + sendFile(response, candidate) + return + } + const adminIndexPath = resolve(distPath, 'admin.html') + if (!existsSync(adminIndexPath)) { + response.statusCode = 503 + response.end('Admin build missing. Run npm run build.') + return + } + sendFile(response, adminIndexPath) +} + +const server = createServer(async (request, response) => { + try { + if (!request.url?.startsWith('/api/')) { + serveStatic(request, response) + return + } + + if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') { + sendBossImage(request, response) + return + } + + if (request.url.startsWith('/api/item-images/') && request.method === 'GET') { + sendItemImage(request, response) + return + } + + if (!existsSync(databasePath)) { + sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' }) + return + } + + const database = new DatabaseSync(databasePath) + database.exec('PRAGMA foreign_keys = ON') + database.exec('PRAGMA journal_mode = WAL') + database.exec('PRAGMA busy_timeout = 5000') + + try { + if (request.url === '/api/admin/data' && request.method === 'GET') { + const items = 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 slot, item_level, id + `).all() + const encounters = database.prepare(` + SELECT id, dungeon_id AS dungeonId, sequence, slug, name AS enemyName, + encounter_type AS encounterType, image_url AS imageUrl + FROM encounters ORDER BY dungeon_id, sequence + `).all() + const difficulties = database.prepare(` + SELECT id, slug, name, dropped_item_level AS droppedItemLevel + FROM difficulties ORDER BY id + `).all() + const encounterLoot = 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() + const craftingRecipes = database.prepare(` + SELECT cr.id, cr.item_id AS itemId, cr.difficulty_id AS difficultyId, + cr.source_dungeon_id AS sourceDungeonId, + cr.source_encounter_id AS sourceEncounterId, + crc.item_id AS componentId, crc.quantity + FROM crafting_recipes cr + LEFT JOIN crafting_recipe_components crc ON crc.recipe_id = cr.id + ORDER BY cr.id, crc.item_id + `).all() + const recipes = new Map() + for (const row of craftingRecipes) { + if (!recipes.has(row.id)) { + recipes.set(row.id, { + id: row.id, itemId: row.itemId, difficultyId: row.difficultyId, + sourceDungeonId: row.sourceDungeonId, sourceEncounterId: row.sourceEncounterId, + components: [], + }) + } + if (row.componentId) { + 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() + sendJson(response, 200, { items, encounters, difficulties, encounterLoot, craftingRecipes: [...recipes.values()], dungeons }) + return + } + + const bossImageMatch = request.url.match(/^\/api\/admin\/encounters\/(\d+)\/image$/) + if (bossImageMatch && request.method === 'PUT') { + const payload = await readJson(request, 6 * 1024 * 1024) + const imageUrl = saveBossImage(database, Number(bossImageMatch[1]), payload) + sendJson(response, 200, { ok: true, imageUrl }) + return + } + + const itemImageMatch = request.url.match(/^\/api\/admin\/items\/(\d+)\/image$/) + if (itemImageMatch && request.method === 'PUT') { + const payload = await readJson(request, 6 * 1024 * 1024) + const imageUrl = saveItemImage(database, Number(itemImageMatch[1]), payload) + sendJson(response, 200, { ok: true, imageUrl }) + return + } + + const itemMatch = request.url.match(/^\/api\/admin\/items\/(\d+)$/) + if (itemMatch && request.method === 'PUT') { + const payload = await readJson(request) + const itemId = Number(itemMatch[1]) + const fields = [] + const values = [] + for (const key of ['name', 'slug', 'slot', 'rarity', 'glyph', 'description']) { + if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(payload[key]) } + } + for (const key of ['item_level', 'healing_power', 'max_resource_bonus']) { + 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(itemId) + database.prepare(`UPDATE items SET ${fields.join(', ')} WHERE id = ?`).run(...values) + sendJson(response, 200, { ok: true }) + return + } + + if (request.url === '/api/admin/encounter-loot' && request.method === 'POST') { + const payload = await readJson(request) + database.prepare(` + INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(encounter_id, difficulty_id, item_id) + 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) + sendJson(response, 200, { ok: true }) + return + } + + const lootDelete = request.url.match(/^\/api\/admin\/encounter-loot\/(\d+)\/(\d+)\/(\d+)$/) + if (lootDelete && request.method === 'DELETE') { + 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])) + sendJson(response, 200, { ok: true }) + return + } + + const recipeComponents = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)\/components$/) + if (recipeComponents && request.method === 'POST') { + const payload = await readJson(request) + database.prepare(` + INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) + VALUES (?, ?, ?) + ON CONFLICT(recipe_id, item_id) + DO UPDATE SET quantity = excluded.quantity + `).run(Number(recipeComponents[1]), payload.itemId, payload.quantity) + sendJson(response, 200, { ok: true }) + return + } + + const recipeComponentDelete = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)\/components\/(\d+)$/) + if (recipeComponentDelete && request.method === 'DELETE') { + database.prepare(`DELETE FROM crafting_recipe_components WHERE recipe_id = ? AND item_id = ?`) + .run(Number(recipeComponentDelete[1]), Number(recipeComponentDelete[2])) + sendJson(response, 200, { ok: true }) + return + } + + sendJson(response, 404, { error: 'Admin API route not found.' }) + } catch (error) { + const status = Number(error?.status) || 400 + sendJson(response, status, { error: error instanceof Error ? error.message : 'Unable to process request.' }) + } finally { + database.close() + } + } catch (error) { + sendJson(response, 500, { error: error instanceof Error ? error.message : 'Server error.' }) + } +}) + +server.listen(port, host, () => { + console.log(`Admin panel listening on http://${host}:${port}`) +}) diff --git a/server/game-api.d.mts b/server/game-api.d.mts new file mode 100644 index 0000000..e786c57 --- /dev/null +++ b/server/game-api.d.mts @@ -0,0 +1,3 @@ +import type { Plugin } from 'vite' + +export function gameApiPlugin(): Plugin diff --git a/server/game-api.mjs b/server/game-api.mjs new file mode 100644 index 0000000..d2b3822 --- /dev/null +++ b/server/game-api.mjs @@ -0,0 +1,2094 @@ +import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { + createHash, + randomBytes, + scryptSync, + timingSafeEqual, +} from 'node:crypto' +import { isIP } from 'node:net' +import { extname, resolve, sep } from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +const databasePath = fileURLToPath(new URL('../data/game.db', 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 bossImageContentTypes = { + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', +} +const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket'] +const componentSlot = 'component' +const sessionCookieName = 'chronicle_session' +const sessionLifetimeSeconds = 60 * 60 * 24 * 30 +const rateLimitBuckets = new Map() + +function sendJson(response, status, body, headers = {}) { + response.statusCode = status + response.setHeader('Content-Type', 'application/json') + for (const [name, value] of Object.entries(headers)) response.setHeader(name, value) + response.end(JSON.stringify(body)) +} + +async function readJson(request, maxSize = 16 * 1024) { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.length + if (size > maxSize) { + const error = new Error('Request body is too large.') + error.status = 413 + throw error + } + chunks.push(chunk) + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function sendBossImage(request, response) { + const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname) + const filename = pathname.replace('/api/boss-images/', '') + if (!/^[A-Za-z0-9._-]+$/.test(filename)) { + sendJson(response, 404, { error: 'Image not found.' }) + return + } + const imagePath = resolve(bossImageDirectory, filename) + const insideImageDirectory = imagePath.startsWith(resolve(bossImageDirectory) + sep) + const extension = extname(imagePath).toLowerCase() + if ( + !insideImageDirectory + || !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 sendItemImage(request, response) { + const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname) + const filename = pathname.replace('/api/item-images/', '') + if (!/^[A-Za-z0-9._-]+$/.test(filename)) { + sendJson(response, 404, { error: 'Image not found.' }) + return + } + const imagePath = resolve(itemImageDirectory, filename) + const insideImageDirectory = imagePath.startsWith(resolve(itemImageDirectory) + sep) + const extension = extname(imagePath).toLowerCase() + if ( + !insideImageDirectory + || !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) { + const encounter = database.prepare(` + SELECT id, slug, encounter_type AS encounterType + FROM encounters + WHERE id = ? + `).get(encounterId) + if (!encounter || encounter.encounterType !== 'boss') { + throw new Error('Boss encounter 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('Boss image must be 1 byte to 4 MB.') + } + mkdirSync(bossImageDirectory, { recursive: true }) + const filename = `${encounter.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}` + writeFileSync(resolve(bossImageDirectory, filename), bytes, { mode: 0o644 }) + const imageUrl = `/api/boss-images/${filename}` + database.prepare(` + UPDATE encounters + SET image_url = ? + WHERE id = ? + `).run(imageUrl, encounterId) + return imageUrl +} + +function saveItemImage(database, itemId, payload) { + const item = database.prepare(` + SELECT id, slug, slot + FROM items + WHERE id = ? + `).get(itemId) + if (!item || item.slot === componentSlot) { + throw new Error('Equipment item 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('Equipment image must be 1 byte to 4 MB.') + } + mkdirSync(itemImageDirectory, { recursive: true }) + const filename = `${item.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}` + writeFileSync(resolve(itemImageDirectory, filename), bytes, { mode: 0o644 }) + const imageUrl = `/api/item-images/${filename}` + database.prepare(` + UPDATE items + SET image_url = ? + WHERE id = ? + `).run(imageUrl, itemId) + return imageUrl +} + +function normalizeIp(value) { + const address = String(value ?? '').trim() + if (address.startsWith('::ffff:') && isIP(address.slice(7)) === 4) { + return address.slice(7) + } + return address +} + +function requestIp(request) { + if (process.env.TRUST_PROXY === '1') { + const forwarded = request.headers['x-forwarded-for'] + if (typeof forwarded === 'string') { + return normalizeIp(forwarded.split(',')[0]) + } + } + return normalizeIp(request.socket.remoteAddress ?? 'unknown') +} + +function consumeRateLimit(key, limit, windowMs) { + const now = Date.now() + const bucket = rateLimitBuckets.get(key) + if (!bucket || now >= bucket.resetAt) { + rateLimitBuckets.set(key, { count: 1, resetAt: now + windowMs }) + return + } + bucket.count += 1 + if (bucket.count > limit) { + const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)) + const error = new Error(`Too many requests. Try again in ${retryAfter} seconds.`) + error.status = 429 + error.retryAfter = retryAfter + throw error + } +} + +function normalizeUsername(value) { + const username = String(value ?? '').trim() + if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) { + throw new Error('Username must be 3-20 letters, numbers, or underscores.') + } + return username +} + +function normalizeCharacterName(value, fallback) { + const name = String(value ?? fallback).trim() + if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) { + throw new Error('Character name must be 2-20 characters and start with a letter.') + } + return name +} + +function validatePassword(value) { + const password = String(value ?? '') + if (password.length < 10 || password.length > 128) { + throw new Error('Password must be 10-128 characters.') + } + return password +} + +function passwordDigest(password, salt) { + return scryptSync(password, salt, 64).toString('hex') +} + +function verifyPassword(password, account) { + const actual = Buffer.from(passwordDigest(password, account.passwordSalt), 'hex') + const expected = Buffer.from(account.passwordHash, 'hex') + return actual.length === expected.length && timingSafeEqual(actual, expected) +} + +function tokenHash(token) { + return createHash('sha256').update(token).digest('hex') +} + +function parseCookies(request) { + return Object.fromEntries( + String(request.headers.cookie ?? '') + .split(';') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + const separator = part.indexOf('=') + return separator < 0 + ? [part, ''] + : [part.slice(0, separator), decodeURIComponent(part.slice(separator + 1))] + }), + ) +} + +function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) { + const secure = request.headers['x-forwarded-proto'] === 'https' + || Boolean(request.socket.encrypted) + || process.env.COOKIE_SECURE === '1' + return [ + `${sessionCookieName}=${encodeURIComponent(token)}`, + 'HttpOnly', + 'Path=/', + 'SameSite=Lax', + `Max-Age=${maxAge}`, + secure ? 'Secure' : '', + ].filter(Boolean).join('; ') +} + +function createSession(database, accountId, ip, activeCharacterId) { + const token = randomBytes(32).toString('base64url') + database.prepare(` + INSERT INTO sessions (account_id, token_hash, active_character_id, expires_at, created_ip) + VALUES (?, ?, ?, datetime('now', '+30 days'), ?) + `).run(accountId, tokenHash(token), activeCharacterId ?? null, ip) + return token +} + +function currentSession(database, request) { + const token = parseCookies(request)[sessionCookieName] + if (!token) return null + return database.prepare(` + SELECT + sessions.id AS sessionId, + accounts.id AS accountId, + accounts.username, + characters.id AS characterId, + characters.class_id AS classId + FROM sessions + JOIN accounts ON accounts.id = sessions.account_id + JOIN characters ON characters.id = sessions.active_character_id + WHERE sessions.token_hash = ? + AND sessions.expires_at > CURRENT_TIMESTAMP + `).get(tokenHash(token)) ?? null +} + +function requireSession(database, request) { + const session = currentSession(database, request) + if (!session) { + const error = new Error('Sign in to continue.') + error.status = 401 + throw error + } + return session +} + +function initializeCharacter(database, accountId, characterName, classId) { + const result = database.prepare(` + INSERT INTO characters (account_id, class_id, name, level, experience, talent_points) + VALUES (?, ?, ?, 1, 0, 1) + `).run(accountId, classId, characterName) + const characterId = Number(result.lastInsertRowid) + const insertSlot = database.prepare(` + INSERT INTO character_ability_slots (character_id, slot_number, spell_id) + VALUES (?, ?, ?) + `) + const starterSpells = database.prepare(` + SELECT id FROM spells WHERE class_id = ? AND unlock_level = 1 ORDER BY id + `).all(classId).map((s) => s.id) + ;[...starterSpells, null].slice(0, 6).forEach((spellId, index) => { + insertSlot.run(characterId, index + 1, spellId) + }) + const insertItem = database.prepare(` + INSERT INTO character_inventory (character_id, item_id, quantity, equipped) + VALUES (?, ?, 1, ?) + `) + for (let itemId = 100; itemId <= 107; itemId += 1) { + insertItem.run(characterId, itemId, itemId === 107 ? 0 : 1) + } + return characterId +} + +function registerAccount(database, request, payload) { + const ip = requestIp(request) + consumeRateLimit(`register:${ip}`, 3, 60 * 60 * 1000) + const username = normalizeUsername(payload.username) + const password = validatePassword(payload.password) + const characterName = normalizeCharacterName(payload.characterName, username) + const existing = database.prepare(` + SELECT id FROM accounts WHERE username = ? COLLATE NOCASE + `).get(username) + if (existing) throw new Error('That username is already taken.') + const accountLimit = database.prepare(` + SELECT max_accounts AS maxAccounts + FROM account_ip_allowances + WHERE ip_address = ? + `).get(ip)?.maxAccounts ?? 1 + const existingIpAccounts = database.prepare(` + SELECT COUNT(*) AS count FROM accounts WHERE created_ip = ? + `).get(ip).count + if (existingIpAccounts >= accountLimit) { + throw new Error( + 'This IP address has reached its account limit. Contact the server administrator for an exception.', + ) + } + + const salt = randomBytes(16).toString('hex') + database.exec('BEGIN') + try { + const accountResult = database.prepare(` + INSERT INTO accounts (username, password_hash, password_salt, created_ip) + VALUES (?, ?, ?, ?) + `).run(username, passwordDigest(password, salt), salt, ip) + const accountId = Number(accountResult.lastInsertRowid) + const activeCharacterId = initializeCharacter(database, accountId, characterName, 1) + initializeCharacter(database, accountId, characterName, 2) + initializeCharacter(database, accountId, characterName, 3) + const token = createSession(database, accountId, ip, activeCharacterId) + database.exec('COMMIT') + return { + token, + account: { id: accountId, username }, + profile: getProfile(database, activeCharacterId, accountId), + } + } catch (error) { + database.exec('ROLLBACK') + throw error + } +} + +function loginAccount(database, request, payload) { + const ip = requestIp(request) + consumeRateLimit(`login:${ip}`, 8, 15 * 60 * 1000) + const username = normalizeUsername(payload.username) + const password = String(payload.password ?? '') + const account = database.prepare(` + SELECT + accounts.id, + accounts.username, + accounts.password_hash AS passwordHash, + accounts.password_salt AS passwordSalt + FROM accounts + WHERE accounts.username = ? COLLATE NOCASE + `).get(username) + if (!account || !verifyPassword(password, account)) { + throw new Error('Invalid username or password.') + } + const activeCharacterId = database.prepare(` + SELECT id FROM characters WHERE account_id = ? ORDER BY class_id LIMIT 1 + `).get(account.id)?.id + if (!activeCharacterId) throw new Error('No character found for this account.') + const token = createSession(database, account.id, ip, activeCharacterId) + return { + token, + account: { id: account.id, username: account.username }, + profile: getProfile(database, activeCharacterId, account.id), + } +} + +export function getProfile(database, characterId, accountId) { + if (!accountId) { + const row = database.prepare('SELECT account_id FROM characters WHERE id = ?').get(characterId) + accountId = row?.account_id ?? null + } + const character = database.prepare(` + SELECT + characters.id, + characters.name, + characters.level, + characters.experience, + characters.talent_points AS talentPoints, + classes.id AS classId, + classes.slug AS classSlug, + classes.name AS className, + classes.resource_name AS resourceName, + classes.max_resource AS maxResource, + classes.theme_color AS themeColor, + classes.description AS classDescription + FROM characters + JOIN classes ON classes.id = characters.class_id + WHERE characters.id = ? + `).get(characterId) + + 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 spells = 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 slots = database.prepare(` + SELECT slot_number AS slotNumber, spell_id AS spellId + FROM character_ability_slots + WHERE character_id = ? + ORDER BY slot_number + `).all(characterId) + 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, + COALESCE(character_talents.rank, 0) AS rank + FROM talents + LEFT JOIN talents AS prerequisite + ON prerequisite.id = talents.prerequisite_talent_id + LEFT JOIN character_talents + ON character_talents.talent_id = talents.id + AND character_talents.character_id = ? + ORDER BY talents.class_id, talents.tier, talents.branch + `).all(characterId) + const inventory = database.prepare(` + SELECT + items.id, + items.slug, + items.name, + items.slot, + items.rarity, + items.item_level AS itemLevel, + items.healing_power AS healingPower, + items.max_resource_bonus AS maxResourceBonus, + items.glyph, + items.description, + item_sets.id AS setId, + item_sets.slug AS setSlug, + item_sets.name AS setName, + character_inventory.quantity, + character_inventory.equipped + FROM character_inventory + JOIN items ON items.id = character_inventory.item_id + LEFT JOIN item_set_items ON item_set_items.item_id = items.id + LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id + WHERE character_inventory.character_id = ? + ORDER BY items.slot, items.item_level DESC, items.id + `).all(characterId).map((item) => ({ ...item, equipped: Boolean(item.equipped) })) + const equippedItems = inventory.filter((item) => item.equipped) + const gearStats = { + averageItemLevel: equipmentSlots.length === 0 + ? 0 + : equippedItems.reduce((total, item) => total + item.itemLevel, 0) / equipmentSlots.length, + healingPower: equippedItems.reduce((total, item) => total + item.healingPower, 0), + maxResourceBonus: equippedItems.reduce((total, item) => total + item.maxResourceBonus, 0), + } + const dungeons = database.prepare(` + SELECT + dungeons.id, + dungeons.slug, + dungeons.name, + dungeons.recommended_level AS recommendedLevel, + dungeons.content_type AS contentType, + dungeons.party_size AS partySize, + dungeons.completion_item_level AS completionItemLevel, + dungeons.experience_reward AS experienceReward, + dungeons.description, + locations.name AS locationName + FROM dungeons + JOIN locations ON locations.id = dungeons.location_id + ORDER BY dungeons.id + `).all() + const dungeonDifficulties = database.prepare(` + SELECT + dungeon_difficulties.dungeon_id AS dungeonId, + difficulties.id, + difficulties.slug, + difficulties.name, + difficulties.dropped_item_level AS droppedItemLevel, + difficulties.unlock_level AS unlockLevel, + difficulties.health_multiplier AS healthMultiplier, + difficulties.damage_multiplier AS damageMultiplier, + difficulties.experience_multiplier AS experienceMultiplier, + difficulties.description + FROM dungeon_difficulties + JOIN difficulties ON difficulties.id = dungeon_difficulties.difficulty_id + ORDER BY dungeon_difficulties.dungeon_id, difficulties.id + `).all() + const encounters = database.prepare(` + SELECT + id, + dungeon_id AS dungeonId, + sequence, + slug, + name AS enemyName, + encounter_type AS encounterType, + max_health AS maxHealth, + base_damage AS damage, + tank_damage AS tankDamage, + party_damage AS partyDamage, + description, + image_url AS imageUrl + FROM encounters + ORDER BY dungeon_id, sequence + `).all() + const encounterLoot = database.prepare(` + SELECT + encounter_loot.encounter_id AS encounterId, + encounter_loot.difficulty_id AS difficultyId, + encounter_loot.drop_weight AS dropWeight, + encounter_loot.drop_chance AS dropChance, + items.id, + items.slug, + items.name, + items.slot, + items.rarity, + items.item_level AS itemLevel, + items.healing_power AS healingPower, + items.max_resource_bonus AS maxResourceBonus, + items.glyph, + items.description, + item_sets.id AS setId, + item_sets.slug AS setSlug, + item_sets.name AS setName + FROM encounter_loot + JOIN items ON items.id = encounter_loot.item_id + LEFT JOIN item_set_items ON item_set_items.item_id = items.id + LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id + ORDER BY encounter_loot.encounter_id, encounter_loot.difficulty_id, items.slot + `).all() + const completionLoot = database.prepare(` + SELECT + dungeon_completion_loot.dungeon_id AS dungeonId, + items.id, + items.slug, + items.name, + items.slot, + items.rarity, + items.item_level AS itemLevel, + items.healing_power AS healingPower, + items.max_resource_bonus AS maxResourceBonus, + items.glyph, + items.description, + item_sets.id AS setId, + item_sets.slug AS setSlug, + item_sets.name AS setName + FROM dungeon_completion_loot + JOIN items ON items.id = dungeon_completion_loot.item_id + LEFT JOIN item_set_items ON item_set_items.item_id = items.id + LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id + ORDER BY dungeon_completion_loot.dungeon_id, items.slot + `).all() + const craftingRecipeRows = database.prepare(` + SELECT + crafting_recipes.id, + crafting_recipes.difficulty_id AS difficultyId, + crafting_recipes.source_dungeon_id AS sourceDungeonId, + crafting_recipes.source_encounter_id AS sourceEncounterId, + output.id AS itemId, + output.slug, + output.name, + output.slot, + output.rarity, + output.item_level AS itemLevel, + output.healing_power AS healingPower, + output.max_resource_bonus AS maxResourceBonus, + output.glyph, + output.description, + item_sets.id AS setId, + item_sets.slug AS setSlug, + item_sets.name AS setName + FROM crafting_recipes + JOIN items AS output ON output.id = crafting_recipes.item_id + LEFT JOIN item_set_items ON item_set_items.item_id = output.id + LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id + ORDER BY crafting_recipes.source_dungeon_id, crafting_recipes.difficulty_id, output.item_level, output.slot + `).all() + const craftingComponentRows = database.prepare(` + SELECT + crafting_recipe_components.recipe_id AS recipeId, + crafting_recipe_components.quantity, + components.id, + components.slug, + components.name, + components.slot, + components.rarity, + components.item_level AS itemLevel, + components.healing_power AS healingPower, + components.max_resource_bonus AS maxResourceBonus, + components.glyph, + components.description, + COALESCE(character_inventory.quantity, 0) AS owned + FROM crafting_recipe_components + JOIN items AS components ON components.id = crafting_recipe_components.item_id + LEFT JOIN character_inventory + ON character_inventory.item_id = components.id + AND character_inventory.character_id = ? + ORDER BY crafting_recipe_components.recipe_id, components.id + `).all(characterId) + const setBonuses = database.prepare(` + SELECT + item_sets.id AS setId, + item_sets.slug AS setSlug, + item_sets.name AS setName, + item_set_bonuses.required_pieces AS requiredPieces, + item_set_bonuses.effect_type AS effectType, + item_set_bonuses.description, + COALESCE(equipped_sets.equippedPieces, 0) AS equippedPieces + FROM item_set_bonuses + JOIN item_sets ON item_sets.id = item_set_bonuses.set_id + LEFT JOIN ( + SELECT item_set_items.set_id AS setId, COUNT(*) AS equippedPieces + FROM item_set_items + JOIN character_inventory + ON character_inventory.item_id = item_set_items.item_id + AND character_inventory.character_id = ? + AND character_inventory.equipped = 1 + GROUP BY item_set_items.set_id + ) AS equipped_sets ON equipped_sets.setId = item_sets.id + ORDER BY item_sets.id, item_set_bonuses.required_pieces + `).all(characterId).map((bonus) => ({ + ...bonus, + active: bonus.equippedPieces >= bonus.requiredPieces, + })) + const leaderboardRuns = database.prepare(` + SELECT + rank, + dungeonId, + difficultyId, + startPart, + completedParts, + characterName, + className, + characterLevel, + averageItemLevel, + resourceSpent, + durationSeconds, + completedAt + FROM ( + SELECT + ROW_NUMBER() OVER ( + PARTITION BY dungeon_id, difficulty_id, start_part, completed_parts + ORDER BY + resource_spent, + average_item_level, + duration_seconds, + completed_at + ) AS rank, + dungeon_id AS dungeonId, + difficulty_id AS difficultyId, + start_part AS startPart, + completed_parts AS completedParts, + character_name AS characterName, + class_name AS className, + character_level AS characterLevel, + average_item_level AS averageItemLevel, + resource_spent AS resourceSpent, + duration_seconds AS durationSeconds, + completed_at AS completedAt + FROM dungeon_runs + WHERE result = 'victory' + AND leaderboard_eligible = 1 + ) + WHERE rank <= 10 + ORDER BY dungeonId, difficultyId, startPart, completedParts, rank + `).all() + + const settings = Object.fromEntries( + database.prepare('SELECT key, value FROM game_settings').all() + .map((setting) => [setting.key, Number(setting.value)]), + ) + const currentLevel = database.prepare(` + SELECT experience_required AS experienceRequired + FROM level_progression + WHERE level = ? + `).get(character.level) + const nextLevel = database.prepare(` + SELECT experience_required AS experienceRequired + FROM level_progression + WHERE level = ? + `).get(character.level + 1) + + return { + character: { + ...character, + currentLevelExperience: currentLevel?.experienceRequired ?? character.experience, + nextLevelExperience: nextLevel?.experienceRequired ?? character.experience, + }, + classes: classes.map((gameClass) => ({ + ...gameClass, + spells: spells.filter((spell) => spell.classId === gameClass.id), + talents: talents.filter((talent) => talent.classId === gameClass.id), + })), + abilitySlots: Array.from({ length: 6 }, (_, index) => { + const slot = slots.find((candidate) => candidate.slotNumber === index + 1) + return slot?.spellId ?? null + }), + maxLevel: settings.max_level ?? 25, + maxTalentPoints: settings.max_talent_points ?? 25, + allocatedTalentPoints: talents.reduce((total, talent) => total + talent.rank, 0), + completedDungeonParts: accountId + ? (database.prepare('SELECT completed_dungeon_parts AS value FROM accounts WHERE id = ?').get(accountId)?.value ?? 0) + : 0, + completedRaidPhases: accountId + ? (database.prepare('SELECT completed_raid_phases AS value FROM accounts WHERE id = ?').get(accountId)?.value ?? 0) + : 0, + equipmentSlots, + inventory, + gearStats, + setBonuses, + craftingRecipes: craftingRecipeRows.map((recipe) => { + const components = craftingComponentRows + .filter((component) => component.recipeId === recipe.id) + .map(({ recipeId, owned, quantity, ...item }) => ({ + item, + quantity, + owned, + })) + const { itemId, slug, name, slot, rarity, itemLevel, healingPower, maxResourceBonus, glyph, description, setId, setSlug, setName } = recipe + return { + id: recipe.id, + difficultyId: recipe.difficultyId, + sourceDungeonId: recipe.sourceDungeonId, + sourceEncounterId: recipe.sourceEncounterId, + item: { + id: itemId, + slug, + name, + slot, + rarity, + itemLevel, + healingPower, + maxResourceBonus, + glyph, + description, + setId, + setSlug, + setName, + }, + components, + canCraft: components.every((component) => component.owned >= component.quantity), + } + }), + dungeons: dungeons.map((dungeon) => ({ + ...dungeon, + difficulties: dungeonDifficulties.filter( + (difficulty) => difficulty.dungeonId === dungeon.id, + ), + encounters: encounters + .filter((encounter) => encounter.dungeonId === dungeon.id) + .map((encounter) => ({ + ...encounter, + isBoss: encounter.encounterType === 'boss', + lootTables: encounterLoot.filter( + (entry) => entry.encounterId === encounter.id, + ), + })), + completionLoot: completionLoot.filter((item) => item.dungeonId === dungeon.id), + leaderboard: leaderboardRuns + .filter((run) => run.dungeonId === dungeon.id) + .map((run) => ({ ...run, rank: Number(run.rank) })), + leaderboards: { + part_1: leaderboardRuns + .filter((run) => run.dungeonId === dungeon.id && run.startPart === 1 && run.completedParts === 1) + .map((run) => ({ ...run, rank: Number(run.rank) })), + part_2: leaderboardRuns + .filter((run) => run.dungeonId === dungeon.id && run.startPart === 2 && run.completedParts === 1) + .map((run) => ({ ...run, rank: Number(run.rank) })), + part_3: leaderboardRuns + .filter((run) => run.dungeonId === dungeon.id && run.startPart === 3 && run.completedParts === 1) + .map((run) => ({ ...run, rank: Number(run.rank) })), + full_run: leaderboardRuns + .filter((run) => run.dungeonId === dungeon.id && run.startPart === 1 && run.completedParts === 3) + .map((run) => ({ ...run, rank: Number(run.rank) })), + }, + })), + } +} + +function itemById(database, itemId) { + return database.prepare(` + SELECT + items.id, + items.slug, + items.name, + items.slot, + items.rarity, + items.item_level AS itemLevel, + items.healing_power AS healingPower, + items.max_resource_bonus AS maxResourceBonus, + items.glyph, + items.description, + item_sets.id AS setId, + item_sets.slug AS setSlug, + item_sets.name AS setName + FROM items + LEFT JOIN item_set_items ON item_set_items.item_id = items.id + LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id + WHERE items.id = ? + `).get(itemId) +} + +function formatLootRoll(database, context, record, dropChance) { + let items = [] + if (record.id) { + items = database.prepare(` + SELECT + items.id, + items.slug, + items.name, + items.slot, + items.rarity, + items.item_level AS itemLevel, + items.healing_power AS healingPower, + items.max_resource_bonus AS maxResourceBonus, + items.glyph, + items.description, + item_sets.id AS setId, + item_sets.slug AS setSlug, + item_sets.name AS setName, + encounter_loot_roll_items.quantity, + encounter_loot_roll_items.was_duplicate AS wasDuplicate, + encounter_loot_roll_items.quantity_after AS quantityAfter + FROM encounter_loot_roll_items + JOIN items ON items.id = encounter_loot_roll_items.item_id + LEFT JOIN item_set_items ON item_set_items.item_id = items.id + LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id + WHERE encounter_loot_roll_items.roll_id = ? + ORDER BY items.id + `).all(record.id).map((item) => ({ + ...item, + duplicate: Boolean(item.wasDuplicate), + })) + } + if (items.length === 0 && record.itemId) { + const item = itemById(database, record.itemId) + if (item) { + items = [{ + ...item, + quantity: 1, + duplicate: Boolean(record.wasDuplicate), + quantityAfter: record.quantityAfter, + }] + } + } + const item = items[0] ?? null + + return { + encounterId: context.id, + encounterName: context.encounterName, + difficultyId: context.difficultyId, + difficultyName: context.difficultyName, + dropChance, + dropped: Boolean(record.dropped), + item, + items, + awarded: Boolean(record.dropped), + duplicate: items.some((candidate) => candidate.duplicate) || Boolean(record.wasDuplicate), + quantityAfter: item?.quantityAfter ?? record.quantityAfter, + } +} + +function componentDropQuantity(droppedItemLevel) { + const tier = Math.max(0, Math.floor((droppedItemLevel - 5) / 5)) + const secondChance = Math.min(0.85, 0.35 + tier * 0.12) + const thirdChance = Math.min(0.6, 0.1 + tier * 0.1) + return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0) +} + +function rollWeightedLootEntry(entries) { + const totalWeight = entries.reduce((total, entry) => total + entry.dropWeight, 0) + let weightedRoll = Math.random() * totalWeight + for (const entry of entries) { + weightedRoll -= entry.dropWeight + if (weightedRoll < 0) return entry + } + return entries[entries.length - 1] +} + +function rollEncounterLoot(database, characterId, encounterId, difficultyId, runToken) { + if (typeof runToken !== 'string' || runToken.length < 8 || runToken.length > 100) { + throw new Error('A valid dungeon run token is required.') + } + + const context = database.prepare(` + SELECT + encounters.id, + encounters.name AS encounterName, + encounters.dungeon_id AS dungeonId, + dungeons.content_type AS contentType, + difficulties.id AS difficultyId, + difficulties.name AS difficultyName, + difficulties.dropped_item_level AS droppedItemLevel, + difficulties.unlock_level AS unlockLevel + FROM encounters + JOIN dungeons ON dungeons.id = encounters.dungeon_id + JOIN dungeon_difficulties + ON dungeon_difficulties.dungeon_id = encounters.dungeon_id + JOIN difficulties + ON difficulties.id = dungeon_difficulties.difficulty_id + WHERE encounters.id = ? AND difficulties.id = ? + `).get(encounterId, difficultyId) + if (!context) throw new Error('That loot table is not available.') + + const character = database.prepare('SELECT level FROM characters WHERE id = ?').get(characterId) + if (character.level < context.unlockLevel) { + throw new Error(`${context.difficultyName} unlocks at level ${context.unlockLevel}.`) + } + + const entries = database.prepare(` + SELECT + encounter_loot.drop_weight AS dropWeight, + encounter_loot.drop_chance AS dropChance, + items.id, + items.slug, + items.name, + items.slot, + items.rarity, + items.item_level AS itemLevel, + items.healing_power AS healingPower, + items.max_resource_bonus AS maxResourceBonus, + items.glyph, + items.description, + item_sets.id AS setId, + item_sets.slug AS setSlug, + item_sets.name AS setName + FROM encounter_loot + JOIN items ON items.id = encounter_loot.item_id + LEFT JOIN item_set_items ON item_set_items.item_id = items.id + LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id + WHERE encounter_loot.encounter_id = ? + AND encounter_loot.difficulty_id = ? + ORDER BY items.id + `).all(encounterId, difficultyId) + if (entries.length === 0) throw new Error('This encounter has no configured loot.') + + const dropChance = entries[0].dropChance + const findExistingRoll = database.prepare(` + SELECT + id, + item_id AS itemId, + dropped, + was_duplicate AS wasDuplicate, + quantity_after AS quantityAfter + FROM encounter_loot_rolls + WHERE character_id = ? + AND run_token = ? + AND encounter_id = ? + AND difficulty_id = ? + `) + const existing = findExistingRoll.get(characterId, runToken, encounterId, difficultyId) + if (existing) { + return formatLootRoll(database, context, existing, dropChance) + } + + database.exec('BEGIN IMMEDIATE') + try { + const concurrentRoll = findExistingRoll.get( + characterId, + runToken, + encounterId, + difficultyId, + ) + if (concurrentRoll) { + database.exec('COMMIT') + return formatLootRoll(database, context, concurrentRoll, dropChance) + } + + const selectedQuantities = new Map() + const lootChanceSlots = context.contentType === 'raid' ? 8 : 5 + for (let index = 0; index < lootChanceSlots; index += 1) { + if (Math.random() >= dropChance) continue + const selected = rollWeightedLootEntry(entries) + selectedQuantities.set( + selected.id, + (selectedQuantities.get(selected.id) ?? 0) + componentDropQuantity(context.droppedItemLevel), + ) + } + + if (selectedQuantities.size === 0) { + database.prepare(` + INSERT INTO encounter_loot_rolls + (character_id, run_token, encounter_id, difficulty_id, dropped) + VALUES (?, ?, ?, ?, 0) + `).run(characterId, runToken, encounterId, difficultyId) + database.exec('COMMIT') + return formatLootRoll(database, context, { + id: null, + itemId: null, + dropped: 0, + wasDuplicate: 0, + quantityAfter: 0, + }, dropChance) + } + + const primaryItemId = selectedQuantities.keys().next().value + const primaryPreviousQuantity = database.prepare(` + SELECT quantity + FROM character_inventory + WHERE character_id = ? AND item_id = ? + `).get(characterId, primaryItemId)?.quantity ?? 0 + const rollResult = database.prepare(` + INSERT INTO encounter_loot_rolls + ( + character_id, + run_token, + encounter_id, + difficulty_id, + item_id, + dropped, + was_duplicate, + quantity_after + ) + VALUES (?, ?, ?, ?, ?, 1, ?, ?) + `).run( + characterId, + runToken, + encounterId, + difficultyId, + primaryItemId, + primaryPreviousQuantity > 0 ? 1 : 0, + primaryPreviousQuantity + selectedQuantities.get(primaryItemId), + ) + const rollId = Number(rollResult.lastInsertRowid) + for (const [itemId, quantity] of selectedQuantities) { + const previousQuantity = database.prepare(` + SELECT quantity + FROM character_inventory + WHERE character_id = ? AND item_id = ? + `).get(characterId, itemId)?.quantity ?? 0 + database.prepare(` + INSERT INTO character_inventory (character_id, item_id, quantity, equipped) + VALUES (?, ?, ?, 0) + ON CONFLICT(character_id, item_id) + DO UPDATE SET quantity = quantity + ? + `).run(characterId, itemId, quantity, quantity) + database.prepare(` + INSERT INTO encounter_loot_roll_items + (roll_id, item_id, quantity, was_duplicate, quantity_after) + VALUES (?, ?, ?, ?, ?) + `).run( + rollId, + itemId, + quantity, + previousQuantity > 0 ? 1 : 0, + previousQuantity + quantity, + ) + } + database.exec('COMMIT') + + return formatLootRoll(database, context, { + id: rollId, + itemId: primaryItemId, + dropped: 1, + wasDuplicate: primaryPreviousQuantity > 0 ? 1 : 0, + quantityAfter: primaryPreviousQuantity + selectedQuantities.get(primaryItemId), + }, dropChance) + } catch (error) { + database.exec('ROLLBACK') + throw error + } +} + +function equipItem(database, characterId, itemId) { + const item = database.prepare(` + SELECT + items.id, + items.slot, + character_inventory.equipped + FROM character_inventory + JOIN items ON items.id = character_inventory.item_id + WHERE character_inventory.character_id = ? + AND items.id = ? + `).get(characterId, itemId) + if (!item) throw new Error('That item is not in the character inventory.') + if (!equipmentSlots.includes(item.slot)) throw new Error('That item cannot be equipped.') + if (item.equipped) return getProfile(database, characterId) + + database.exec('BEGIN') + try { + database.prepare(` + UPDATE character_inventory + SET equipped = 0 + WHERE character_id = ? + AND item_id IN (SELECT id FROM items WHERE slot = ?) + `).run(characterId, item.slot) + database.prepare(` + UPDATE character_inventory + SET equipped = 1 + WHERE character_id = ? AND item_id = ? + `).run(characterId, itemId) + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + + return getProfile(database, characterId) +} + +function discardExtraItem(database, characterId, itemId) { + const item = database.prepare(` + SELECT + items.name, + character_inventory.quantity + FROM character_inventory + JOIN items ON items.id = character_inventory.item_id + WHERE character_inventory.character_id = ? + AND items.id = ? + `).get(characterId, itemId) + if (!item) throw new Error('That item is not in the character inventory.') + if (item.quantity <= 1) { + throw new Error('Only extra copies can be discarded.') + } + + database.exec('BEGIN') + try { + const result = database.prepare(` + UPDATE character_inventory + SET quantity = quantity - 1 + WHERE character_id = ? + AND item_id = ? + AND quantity > 1 + `).run(characterId, itemId) + if (result.changes !== 1) { + throw new Error('The extra copy is no longer available.') + } + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + + return getProfile(database, characterId) +} + +function breakdownItem(database, characterId, itemId) { + const item = database.prepare(` + SELECT + items.id, + items.name, + items.slot, + items.item_level AS itemLevel, + character_inventory.quantity AS qty, + character_inventory.equipped + FROM character_inventory + JOIN items ON items.id = character_inventory.item_id + WHERE character_inventory.character_id = ? + AND items.id = ? + `).get(characterId, itemId) + if (!item) throw new Error('That item is not in the character inventory.') + if (item.slot === componentSlot) throw new Error('Components cannot be broken down.') + if (item.equipped && item.qty <= 1) { + throw new Error('Equipped items cannot be broken down.') + } + + const component = database.prepare(` + SELECT id + FROM items + WHERE slot = ? + AND item_level <= ? + ORDER BY item_level DESC, id + LIMIT 1 + `).get(componentSlot, item.itemLevel) + if (!component) throw new Error('No component type exists for this item level.') + + const count = Math.floor(Math.random() * 3) + 1 + + database.exec('BEGIN') + try { + if (item.qty <= 1) { + database.prepare(` + DELETE FROM character_inventory + WHERE character_id = ? AND item_id = ? + `).run(characterId, itemId) + } else { + database.prepare(` + UPDATE character_inventory + SET quantity = quantity - 1 + WHERE character_id = ? AND item_id = ? AND quantity > 1 + `).run(characterId, itemId) + } + + database.prepare(` + INSERT INTO character_inventory (character_id, item_id, quantity, equipped) + VALUES (?, ?, ?, 0) + ON CONFLICT(character_id, item_id) + DO UPDATE SET quantity = quantity + ? + `).run(characterId, component.id, count, count) + + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + + return getProfile(database, characterId) +} + +function craftItem(database, characterId, recipeId) { + const recipe = database.prepare(` + SELECT + crafting_recipes.id, + crafting_recipes.item_id AS itemId, + crafting_recipes.difficulty_id AS difficultyId, + crafting_recipes.source_dungeon_id AS sourceDungeonId, + crafting_recipes.source_encounter_id AS sourceEncounterId + FROM crafting_recipes + WHERE crafting_recipes.id = ? + `).get(recipeId) + if (!recipe) throw new Error('That crafting recipe does not exist.') + + const components = database.prepare(` + SELECT + crafting_recipe_components.item_id AS itemId, + crafting_recipe_components.quantity, + COALESCE(character_inventory.quantity, 0) AS owned + FROM crafting_recipe_components + LEFT JOIN character_inventory + ON character_inventory.item_id = crafting_recipe_components.item_id + AND character_inventory.character_id = ? + WHERE crafting_recipe_components.recipe_id = ? + `).all(characterId, recipeId) + if (components.length === 0) throw new Error('That recipe has no component requirements.') + const missing = components.find((component) => component.owned < component.quantity) + if (missing) { + const item = itemById(database, missing.itemId) + throw new Error(`Need ${missing.quantity} ${item?.name ?? 'component'} to craft this item.`) + } + + database.exec('BEGIN') + try { + for (const component of components) { + database.prepare(` + UPDATE character_inventory + SET quantity = quantity - ? + WHERE character_id = ? AND item_id = ? + `).run(component.quantity, characterId, component.itemId) + database.prepare(` + DELETE FROM character_inventory + WHERE character_id = ? AND item_id = ? AND quantity <= 0 + `).run(characterId, component.itemId) + } + + database.prepare(` + INSERT INTO character_inventory (character_id, item_id, quantity, equipped) + VALUES (?, ?, 1, 0) + ON CONFLICT(character_id, item_id) + DO UPDATE SET quantity = quantity + 1 + `).run(characterId, recipe.itemId) + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + + return getProfile(database, characterId) +} + +function allocateTalent(database, characterId, talentId) { + const character = database.prepare(` + SELECT class_id AS classId, talent_points AS talentPoints + FROM characters + WHERE id = ? + `).get(characterId) + const talent = database.prepare(` + SELECT + id, + class_id AS classId, + name, + max_rank AS maxRank, + tier, + prerequisite_talent_id AS prerequisiteTalentId, + prerequisite_rank AS prerequisiteRank + FROM talents + WHERE id = ? + `).get(talentId) + + if (!talent || talent.classId !== character.classId) { + throw new Error('That talent does not belong to the active class.') + } + if (character.talentPoints <= 0) { + throw new Error('No talent points are available.') + } + + const currentRank = database.prepare(` + SELECT rank + FROM character_talents + WHERE character_id = ? AND talent_id = ? + `).get(characterId, talentId)?.rank ?? 0 + if (currentRank >= talent.maxRank) { + throw new Error('That talent is already at maximum rank.') + } + + const lowerTierPoints = database.prepare(` + SELECT COALESCE(SUM(character_talents.rank), 0) AS points + FROM character_talents + JOIN talents ON talents.id = character_talents.talent_id + WHERE character_talents.character_id = ? + AND talents.class_id = ? + AND talents.tier < ? + `).get(characterId, character.classId, talent.tier).points + const requiredTierPoints = (talent.tier - 1) * 5 + if (lowerTierPoints < requiredTierPoints) { + throw new Error(`Spend ${requiredTierPoints} points in earlier tiers first.`) + } + + if (talent.prerequisiteTalentId) { + const prerequisiteRank = database.prepare(` + SELECT rank + FROM character_talents + WHERE character_id = ? AND talent_id = ? + `).get(characterId, talent.prerequisiteTalentId)?.rank ?? 0 + if (prerequisiteRank < talent.prerequisiteRank) { + throw new Error(`The prerequisite talent requires rank ${talent.prerequisiteRank}.`) + } + } + + database.exec('BEGIN') + try { + database.prepare(` + INSERT INTO character_talents (character_id, talent_id, rank) + VALUES (?, ?, 1) + ON CONFLICT(character_id, talent_id) + DO UPDATE SET rank = rank + 1 + `).run(characterId, talentId) + database.prepare(` + UPDATE characters SET talent_points = talent_points - 1 WHERE id = ? + `).run(characterId) + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + + return getProfile(database, characterId) +} + +function resetTalents(database, characterId) { + const character = database.prepare(` + SELECT class_id AS classId, level, talent_points AS talentPoints + FROM characters + WHERE id = ? + `).get(characterId) + const refunded = database.prepare(` + SELECT COALESCE(SUM(character_talents.rank), 0) AS points + FROM character_talents + JOIN talents ON talents.id = character_talents.talent_id + WHERE character_talents.character_id = ? + AND talents.class_id = ? + `).get(characterId, character.classId).points + + database.exec('BEGIN') + try { + database.prepare(` + DELETE FROM character_talents + WHERE character_id = ? + AND talent_id IN (SELECT id FROM talents WHERE class_id = ?) + `).run(characterId, character.classId) + database.prepare(` + UPDATE characters + SET talent_points = MIN(level, talent_points + ?) + WHERE id = ? + `).run(refunded, characterId) + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + + return getProfile(database, characterId) +} + +function completeDungeon(database, characterId, accountId, dungeonId, difficultyId, runMetrics) { + const resourceSpent = Number(runMetrics?.resourceSpent) + const durationSeconds = Number(runMetrics?.durationSeconds) + if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) { + throw new Error('The run resource total is invalid.') + } + if (!Number.isInteger(durationSeconds) || durationSeconds < 1 || durationSeconds > 86400) { + throw new Error('The run duration is invalid.') + } + + const dungeon = database.prepare(` + SELECT + dungeons.id, + dungeons.name, + dungeons.content_type AS contentType, + dungeons.completion_item_level AS completionItemLevel, + dungeons.experience_reward AS experienceReward, + difficulties.id AS difficultyId, + difficulties.name AS difficultyName, + difficulties.unlock_level AS unlockLevel, + difficulties.experience_multiplier AS experienceMultiplier, + difficulties.dropped_item_level AS droppedItemLevel + FROM dungeons + JOIN dungeon_difficulties + ON dungeon_difficulties.dungeon_id = dungeons.id + JOIN difficulties + ON difficulties.id = dungeon_difficulties.difficulty_id + WHERE dungeons.id = ? AND difficulties.id = ? + `).get(dungeonId, difficultyId) + if (!dungeon) throw new Error('That difficulty is not available for this dungeon.') + + const character = database.prepare(` + SELECT + characters.id, + characters.class_id AS classId, + characters.name, + characters.level, + characters.experience, + characters.talent_points AS talentPoints, + classes.name AS className + FROM characters + JOIN classes ON classes.id = characters.class_id + WHERE characters.id = ? + `).get(characterId) + if (character.level < dungeon.unlockLevel) { + throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`) + } + const maxLevel = Number( + database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25, + ) + const maxTalentPoints = Number( + database.prepare("SELECT value FROM game_settings WHERE key = 'max_talent_points'").get()?.value ?? 25, + ) + const maxExperience = database.prepare(` + SELECT experience_required AS experienceRequired + FROM level_progression + WHERE level = ? + `).get(maxLevel).experienceRequired + const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3) + const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3) + const completedParts = completedPart - startPart + 1 + const rawPartDurations = runMetrics?.partDurationSeconds + const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3 + ? rawPartDurations.map(Number) + : null + const experienceReward = Math.round( + dungeon.experienceReward * dungeon.experienceMultiplier * completedPart, + ) + const newExperience = Math.min(character.experience + experienceReward, maxExperience) + const newLevel = database.prepare(` + SELECT MAX(level) AS level + FROM level_progression + WHERE experience_required <= ? + `).get(newExperience).level + const levelsGained = Math.max(0, newLevel - character.level) + const newTalentPoints = Math.min( + maxTalentPoints, + character.talentPoints + levelsGained, + ) + const unlockedAbilities = database.prepare(` + SELECT id, name, unlock_level AS unlockLevel, glyph + FROM spells + WHERE class_id = ? + AND unlock_level > ? + AND unlock_level <= ? + ORDER BY unlock_level, id + `).all(character.classId, character.level, newLevel) + const averageItemLevel = database.prepare(` + SELECT COALESCE(SUM(items.item_level), 0) * 1.0 / ? AS value + FROM character_inventory + JOIN items ON items.id = character_inventory.item_id + WHERE character_inventory.character_id = ? + AND character_inventory.equipped = 1 + `).get(equipmentSlots.length, characterId).value + + const isFullRun = startPart === 1 && completedPart >= 3 + const runRows = isFullRun + ? [ + { startPart: 1, completedParts: 1, duration: Math.max(1, Math.round(partDurationSeconds[0] || 1)) }, + { startPart: 2, completedParts: 1, duration: Math.max(1, Math.round(partDurationSeconds[1] || 1)) }, + { startPart: 3, completedParts: 1, duration: Math.max(1, Math.round(partDurationSeconds[2] || 1)) }, + { startPart: 1, completedParts: 3, duration: durationSeconds }, + ] + : [{ startPart, completedParts: completedParts, duration: durationSeconds }] + + database.exec('BEGIN') + try { + database.prepare(` + UPDATE characters + SET experience = ?, level = ?, talent_points = ? + WHERE id = ? + `).run(newExperience, newLevel, newTalentPoints, characterId) + if (dungeon.contentType === 'raid') { + database.prepare(` + UPDATE accounts + SET completed_raid_phases = MAX(completed_raid_phases, ?) + WHERE id = ? + `).run(completedPart, accountId) + } else { + database.prepare(` + UPDATE accounts + SET completed_dungeon_parts = MAX(completed_dungeon_parts, ?) + WHERE id = ? + `).run(completedPart, accountId) + } + const insertStmt = database.prepare(` + INSERT INTO dungeon_runs + ( + character_id, dungeon_id, difficulty_id, result, + character_name, class_name, character_level, + average_item_level, resource_spent, duration_seconds, + leaderboard_eligible, start_part, completed_parts, completed_at + ) + VALUES (?, ?, ?, 'victory', ?, ?, ?, ?, ?, ?, 1, ?, ?, CURRENT_TIMESTAMP) + `) + for (const row of runRows) { + insertStmt.run( + characterId, dungeonId, difficultyId, + character.name, character.className, character.level, + averageItemLevel, resourceSpent, row.duration, + row.startPart, row.completedParts, + ) + } + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + + let bonusItem = null + if (startPart === 1 && completedPart >= 3) { + const bonusItems = database.prepare(` + SELECT items.id, items.slug, items.name, items.slot, items.rarity, + items.item_level AS itemLevel, items.healing_power AS healingPower, + items.max_resource_bonus AS maxResourceBonus, items.glyph, items.description + FROM dungeon_completion_loot + JOIN items ON items.id = dungeon_completion_loot.item_id + WHERE dungeon_completion_loot.dungeon_id = ? + AND items.item_level >= ? + ORDER BY items.item_level, RANDOM() + LIMIT 1 + `).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3) + if (bonusItems.length > 0) { + bonusItem = bonusItems[0] + const previousQuantity = database.prepare(` + SELECT quantity FROM character_inventory + WHERE character_id = ? AND item_id = ? + `).get(characterId, bonusItem.id)?.quantity ?? 0 + database.prepare(` + INSERT INTO character_inventory (character_id, item_id, quantity, equipped) + VALUES (?, ?, 1, 0) + ON CONFLICT(character_id, item_id) + DO UPDATE SET quantity = quantity + 1 + `).run(characterId, bonusItem.id) + bonusItem = { ...bonusItem, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 } + } + } + + return { + dungeonName: dungeon.name, + difficultyName: dungeon.difficultyName, + droppedItemLevel: dungeon.droppedItemLevel, + experienceGained: newExperience - character.experience, + previousLevel: character.level, + newLevel, + levelsGained, + talentPointsGained: levelsGained, + resourceSpent, + durationSeconds, + averageItemLevel, + unlockedAbilities, + bonusItem, + profile: getProfile(database, characterId, accountId), + } +} + +function completeRoguelike(database, characterId, accountId, runMetrics) { + const dungeonId = Number(runMetrics?.dungeonId) + const difficultyId = Number(runMetrics?.difficultyId) + const encountersCleared = Number(runMetrics?.encountersCleared) + const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3)) + const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level' + ? 'pvp-boss-quarter-level' + : 'default' + const resourceSpent = Number(runMetrics?.resourceSpent) + const durationSeconds = Number(runMetrics?.durationSeconds) + if (!Number.isInteger(dungeonId) || dungeonId < 1) { + throw new Error('The roguelike dungeon is invalid.') + } + if (!Number.isInteger(difficultyId) || difficultyId < 1) { + throw new Error('The roguelike difficulty is invalid.') + } + if (!Number.isInteger(encountersCleared) || encountersCleared < 0 || encountersCleared > 100000) { + throw new Error('The roguelike progress total is invalid.') + } + if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) { + throw new Error('The roguelike boss total is invalid.') + } + if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) { + throw new Error('The run resource total is invalid.') + } + if (!Number.isInteger(durationSeconds) || durationSeconds < 1 || durationSeconds > 86400) { + throw new Error('The run duration is invalid.') + } + + const dungeon = database.prepare(` + SELECT + dungeons.id, + dungeons.name, + difficulties.id AS difficultyId, + difficulties.name AS difficultyName, + difficulties.unlock_level AS unlockLevel, + difficulties.experience_multiplier AS experienceMultiplier, + difficulties.dropped_item_level AS droppedItemLevel, + dungeons.experience_reward AS experienceReward + FROM dungeons + JOIN dungeon_difficulties + ON dungeon_difficulties.dungeon_id = dungeons.id + JOIN difficulties + ON difficulties.id = dungeon_difficulties.difficulty_id + WHERE dungeons.id = ? AND difficulties.id = ? + `).get(dungeonId, difficultyId) + if (!dungeon) throw new Error('That difficulty is not available for this roguelike.') + + const character = database.prepare(` + SELECT + characters.id, + characters.class_id AS classId, + characters.level, + characters.experience, + characters.talent_points AS talentPoints + FROM characters + WHERE characters.id = ? + `).get(characterId) + if (character.level < dungeon.unlockLevel) { + throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`) + } + const maxLevel = Number( + database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25, + ) + const maxTalentPoints = Number( + database.prepare("SELECT value FROM game_settings WHERE key = 'max_talent_points'").get()?.value ?? 25, + ) + const maxExperience = database.prepare(` + SELECT experience_required AS experienceRequired + FROM level_progression + WHERE level = ? + `).get(maxLevel).experienceRequired + let newExperience = character.experience + let newLevel = character.level + if (experienceMode === 'pvp-boss-quarter-level') { + for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) { + 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) + newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * 0.25)) + newLevel = database.prepare(` + SELECT MAX(level) AS level + FROM level_progression + WHERE experience_required <= ? + `).get(newExperience).level + } + } else { + const experienceReward = Math.round( + dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3), + ) + newExperience = Math.min(character.experience + experienceReward, maxExperience) + newLevel = database.prepare(` + SELECT MAX(level) AS level + FROM level_progression + WHERE experience_required <= ? + `).get(newExperience).level + } + const levelsGained = Math.max(0, newLevel - character.level) + const newTalentPoints = Math.min( + maxTalentPoints, + character.talentPoints + levelsGained, + ) + const unlockedAbilities = database.prepare(` + SELECT id, name, unlock_level AS unlockLevel, glyph + FROM spells + WHERE class_id = ? + AND unlock_level > ? + AND unlock_level <= ? + ORDER BY unlock_level, id + `).all(character.classId, character.level, newLevel) + const averageItemLevel = database.prepare(` + SELECT COALESCE(SUM(items.item_level), 0) * 1.0 / ? AS value + FROM character_inventory + JOIN items ON items.id = character_inventory.item_id + WHERE character_inventory.character_id = ? + AND character_inventory.equipped = 1 + `).get(equipmentSlots.length, characterId).value + + database.prepare(` + UPDATE characters + SET experience = ?, level = ?, talent_points = ? + WHERE id = ? + `).run(newExperience, newLevel, newTalentPoints, characterId) + + return { + dungeonName: `${dungeon.name} Roguelike`, + difficultyName: dungeon.difficultyName, + droppedItemLevel: dungeon.droppedItemLevel, + experienceGained: newExperience - character.experience, + previousLevel: character.level, + newLevel, + levelsGained, + talentPointsGained: levelsGained, + resourceSpent, + durationSeconds, + averageItemLevel, + unlockedAbilities, + bonusItem: null, + profile: getProfile(database, characterId, accountId), + } +} + +function saveProfile(database, characterId, accountId, payload) { + const classId = Number(payload.classId) + const spellIds = Array.isArray(payload.abilitySlots) + ? payload.abilitySlots.slice(0, 6).map((value) => value === null ? null : Number(value)) + : [] + + while (spellIds.length < 6) spellIds.push(null) + + const gameClass = database.prepare('SELECT id FROM classes WHERE id = ?').get(classId) + if (!gameClass) throw new Error('Selected class does not exist.') + + const getCharacterLevel = database.prepare(` + SELECT level, class_id AS classId + FROM characters + WHERE id = ? + `) + const character = getCharacterLevel.get(characterId) + if (!character) throw new Error('Character not found.') + + const selectedIds = spellIds.filter((id) => id !== null) + if (new Set(selectedIds).size !== selectedIds.length) { + throw new Error('The same ability cannot be equipped twice.') + } + + if (selectedIds.length > 0) { + const placeholders = selectedIds.map(() => '?').join(', ') + const validSpells = database.prepare(` + SELECT id + FROM spells + WHERE class_id = ? + AND unlock_level <= ? + AND id IN (${placeholders}) + `).all(classId, character.level, ...selectedIds) + + if (validSpells.length !== selectedIds.length) { + throw new Error('One or more abilities are locked or belong to another class.') + } + } + + database.exec('BEGIN') + try { + let targetCharacterId = characterId + if (character.classId !== classId) { + const existing = database.prepare(` + SELECT id FROM characters WHERE account_id = ? AND class_id = ? + `).get(accountId, classId) + if (existing) { + targetCharacterId = existing.id + } else { + const char = database.prepare('SELECT name FROM characters WHERE id = ?').get(characterId) + targetCharacterId = initializeCharacter(database, accountId, char.name, classId) + } + database.prepare(` + UPDATE sessions SET active_character_id = ? WHERE account_id = ? + `).run(targetCharacterId, accountId) + } + database.prepare('DELETE FROM character_ability_slots WHERE character_id = ?').run(targetCharacterId) + const insertSlot = database.prepare(` + INSERT INTO character_ability_slots (character_id, slot_number, spell_id) + VALUES (?, ?, ?) + `) + spellIds.forEach((spellId, index) => { + insertSlot.run(targetCharacterId, index + 1, spellId) + }) + database.exec('COMMIT') + return targetCharacterId + } catch (error) { + database.exec('ROLLBACK') + throw error + } +} + +export function gameApiPlugin() { + return { + name: 'ashen-halls-game-api', + configureServer(server) { + server.middlewares.use(handleApiRequest) + }, + configurePreviewServer(server) { + server.middlewares.use(handleApiRequest) + }, + } +} + +export async function handleApiRequest(request, response, next) { + if (!request.url?.startsWith('/api/')) { + next() + return + } + + if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') { + sendBossImage(request, response) + return + } + + if (request.url.startsWith('/api/item-images/') && request.method === 'GET') { + sendItemImage(request, response) + return + } + + if (!existsSync(databasePath)) { + sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' }) + return + } + + const database = new DatabaseSync(databasePath) + database.exec('PRAGMA foreign_keys = ON') + + try { + const ip = requestIp(request) + consumeRateLimit(`api:${ip}`, 240, 60 * 1000) + database.prepare(` + DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP + `).run() + + if (request.url === '/api/auth/register' && request.method === 'POST') { + const payload = await readJson(request) + const result = registerAccount(database, request, payload) + sendJson( + response, + 201, + { account: result.account, profile: result.profile }, + { 'Set-Cookie': sessionCookie(result.token, request) }, + ) + return + } + + if (request.url === '/api/auth/login' && request.method === 'POST') { + const payload = await readJson(request) + const result = loginAccount(database, request, payload) + sendJson( + response, + 200, + { account: result.account, profile: result.profile }, + { 'Set-Cookie': sessionCookie(result.token, request) }, + ) + return + } + + if (request.url === '/api/auth/session' && request.method === 'GET') { + const session = currentSession(database, request) + if (!session) { + sendJson(response, 200, { account: null, profile: null }) + return + } + sendJson(response, 200, { + account: { id: session.accountId, username: session.username }, + profile: getProfile(database, session.characterId, session.accountId), + }) + return + } + + if (request.url === '/api/auth/logout' && request.method === 'POST') { + const token = parseCookies(request)[sessionCookieName] + if (token) { + database.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash(token)) + } + sendJson( + response, + 200, + { ok: true }, + { 'Set-Cookie': sessionCookie('', request, 0) }, + ) + return + } + + const session = requireSession(database, request) + + if (request.url === '/api/profile' && request.method === 'GET') { + sendJson(response, 200, getProfile(database, session.characterId, session.accountId)) + return + } + + if (request.url === '/api/profile' && request.method === 'PUT') { + const payload = await readJson(request) + const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload) + sendJson(response, 200, getProfile(database, newCharacterId, session.accountId)) + return + } + + const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/) + if (dungeonCompletion && request.method === 'POST') { + const payload = await readJson(request) + sendJson( + response, + 200, + completeDungeon( + database, + session.characterId, + session.accountId, + Number(dungeonCompletion[1]), + Number(payload.difficultyId), + payload, + ), + ) + return + } + + if (request.url === '/api/roguelike/complete' && request.method === 'POST') { + const payload = await readJson(request) + sendJson( + response, + 200, + completeRoguelike(database, session.characterId, session.accountId, payload), + ) + return + } + + const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/) + if (talentAllocation && request.method === 'POST') { + sendJson( + response, + 200, + allocateTalent(database, session.characterId, Number(talentAllocation[1])), + ) + return + } + + if (request.url === '/api/talents/reset' && request.method === 'POST') { + sendJson(response, 200, resetTalents(database, session.characterId)) + return + } + + const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/) + if (itemEquip && request.method === 'POST') { + sendJson( + response, + 200, + equipItem(database, session.characterId, Number(itemEquip[1])), + ) + return + } + + const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/) + if (itemDiscard && request.method === 'POST') { + sendJson( + response, + 200, + discardExtraItem(database, session.characterId, Number(itemDiscard[1])), + ) + return + } + + const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/) + if (itemBreakdown && request.method === 'POST') { + sendJson( + response, + 200, + breakdownItem(database, session.characterId, Number(itemBreakdown[1])), + ) + return + } + + const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/) + if (recipeCraft && request.method === 'POST') { + sendJson( + response, + 200, + craftItem(database, session.characterId, Number(recipeCraft[1])), + ) + return + } + + const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/) + if (encounterLootRoll && request.method === 'POST') { + const payload = await readJson(request) + sendJson( + response, + 200, + rollEncounterLoot( + database, + session.characterId, + Number(encounterLootRoll[1]), + Number(payload.difficultyId), + payload.runToken, + ), + ) + return + } + + sendJson(response, 404, { error: 'API route not found.' }) + } catch (error) { + const status = Number(error?.status) || 400 + const headers = error?.retryAfter + ? { 'Retry-After': String(error.retryAfter) } + : {} + sendJson( + response, + status, + { error: error instanceof Error ? error.message : 'Unable to process request.' }, + headers, + ) + } finally { + database.close() + } +} diff --git a/server/production.mjs b/server/production.mjs new file mode 100644 index 0000000..655613f --- /dev/null +++ b/server/production.mjs @@ -0,0 +1,77 @@ +import { createReadStream, existsSync, statSync } from 'node:fs' +import { createServer } from 'node:http' +import { extname, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import { handleApiRequest } from './game-api.mjs' + +process.env.NODE_ENV = 'production' + +const distPath = fileURLToPath(new URL('../dist', import.meta.url)) +const indexPath = resolve(distPath, 'index.html') +const host = process.env.HOST ?? '127.0.0.1' +const port = Number(process.env.PORT ?? 4173) +const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', +} + +function sendFile(response, filePath) { + response.statusCode = 200 + response.setHeader( + 'Content-Type', + contentTypes[extname(filePath).toLowerCase()] ?? 'application/octet-stream', + ) + response.setHeader('X-Content-Type-Options', 'nosniff') + response.setHeader('Referrer-Policy', 'same-origin') + response.setHeader('X-Frame-Options', 'DENY') + createReadStream(filePath).pipe(response) +} + +async function serveStatic(request, response) { + const requestPath = decodeURIComponent(new URL(request.url, 'http://localhost').pathname) + const candidate = resolve(distPath, `.${requestPath}`) + const insideDist = candidate === distPath || candidate.startsWith(`${distPath}${sep}`) + + if (requestPath === '/admin.html') { + response.statusCode = 404 + response.end('Not found') + return + } + + if ( + insideDist + && existsSync(candidate) + && statSync(candidate).isFile() + ) { + sendFile(response, candidate) + return + } + + if (!existsSync(indexPath)) { + response.statusCode = 503 + response.end('Build missing. Run npm run build.') + return + } + sendFile(response, indexPath) +} + +const server = createServer((request, response) => { + handleApiRequest(request, response, () => { + serveStatic(request, response).catch(() => { + response.statusCode = 500 + response.end('Unable to serve the application.') + }) + }) +}) + +server.listen(port, host, () => { + console.log(`I want to Heal listening on http://${host}:${port}`) +}) diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..bb1ca7b --- /dev/null +++ b/src/App.css @@ -0,0 +1,4955 @@ +@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap'); + +:root { + --ink: #f4eed8; + --muted: #a89f87; + --panel: #191b22; + --panel-light: #242630; + --edge: #565066; + --gold: #e5b95f; + --red: #a73543; + --red-bright: #dc5162; + --green: #3f9a66; + --blue: #3477bb; + --purple: #8e68c4; +} + +* { + box-sizing: border-box; +} + +button { + font: inherit; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible { + outline: 3px solid #fff4a8 !important; + box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65); +} + +.settings-heading { + align-items: end; + border-bottom: 2px solid #34343d; + display: flex; + justify-content: space-between; + padding: 12px 0 22px; +} + +.settings-heading > p { + color: var(--muted); + font-size: 19px; +} + +.binding-tabs { + display: grid; + gap: 10px; + grid-template-columns: 1fr 1fr; + margin: 22px 0; +} + +.binding-tabs button, +.binding-list button { + background: #15171d; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + outline: 2px solid #41404a; +} + +.binding-tabs button { + font-family: 'Press Start 2P', monospace; + font-size: 8px; + min-height: 48px; +} + +.binding-tabs button.selected { + color: var(--gold); + outline-color: var(--gold); +} + +.binding-list { + display: grid; + gap: 9px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.binding-list button { + align-items: center; + display: flex; + justify-content: space-between; + min-height: 54px; + padding: 10px 13px; + text-align: left; +} + +.binding-list button:hover, +.binding-list button.listening { + outline-color: var(--gold); +} + +.binding-list button > span { + color: var(--muted); + font-size: 18px; +} + +.binding-list kbd { + align-items: center; + color: var(--gold); + display: inline-flex; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + justify-content: flex-end; + min-height: 25px; + text-align: right; +} + +.controller-face-icon { + --button-color: var(--gold); + align-items: center; + background: var(--button-color); + border: 2px solid #07080a; + border-radius: 999px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28), 0 2px 0 #07080a; + color: #fff8e6; + display: inline-flex; + flex: 0 0 auto; + font-family: Arial, Helvetica, sans-serif; + font-size: 13px; + font-weight: 900; + height: 24px; + justify-content: center; + line-height: 1; + min-width: 24px; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.65); + vertical-align: middle; +} + +.controller-face-playstation { + background: transparent; + border: 0; + box-shadow: none; + color: var(--button-color); + font-size: 25px; + height: 26px; + min-width: 26px; + text-shadow: 0 1px 0 #07080a, 0 0 8px rgba(0, 0, 0, 0.5); +} + +.controller-face-nintendo { + border-color: #fff8e6; +} + +.settings-footer { + align-items: center; + border-top: 2px solid #34343d; + color: var(--muted); + display: flex; + font-size: 18px; + justify-content: space-between; + margin-top: 22px; + padding-top: 18px; +} + +.controller-preferences { + align-items: center; + background: #111319; + border: 2px solid #090a0d; + display: grid; + gap: 14px; + grid-template-columns: minmax(0, 1fr) auto; + margin-top: 22px; + outline: 2px solid #41404a; + padding: 18px; +} + +.controller-preferences p:not(.eyebrow) { + color: var(--muted); + font-size: 17px; + margin-top: 6px; +} + +.controller-preferences button, +.controller-icon-options { + background: #15171d; + border: 2px solid #090a0d; + color: var(--ink); + outline: 2px solid #41404a; +} + +.controller-preferences button { + cursor: pointer; + min-height: 42px; + padding: 8px 12px; +} + +.controller-preferences button.selected { + color: var(--gold); + outline-color: var(--gold); +} + +.controller-icon-options { + align-items: center; + display: grid; + gap: 8px; + grid-column: 1 / -1; + grid-template-columns: minmax(140px, 1fr) repeat(3, minmax(135px, auto)); + padding: 8px; +} + +.controller-icon-options > span { + color: var(--muted); + font-family: 'Press Start 2P', monospace; + font-size: 8px; + padding-left: 7px; +} + +.controller-icon-options button { + align-items: center; + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + gap: 8px; + justify-content: center; +} + +.controller-style-preview { + align-items: center; + display: inline-grid; + gap: 3px; + grid-auto-flow: column; +} + +.controller-style-preview .controller-face-icon { + height: 21px; + min-width: 21px; +} + +.controller-style-preview .controller-face-playstation { + font-size: 22px; +} + +.controller-style-name { + color: var(--ink); + line-height: 1.3; +} + +.dual-screen-settings { + background: #111319; + border: 2px solid #090a0d; + display: grid; + gap: 18px; + grid-template-columns: minmax(0, 1fr) auto; + margin: 12px 0 24px; + outline: 2px solid #41404a; + padding: 20px; +} + +.dual-screen-settings p:not(.eyebrow) { + color: var(--muted); + font-size: 19px; + line-height: 1.2; + margin-top: 8px; + max-width: 680px; +} + +.dual-screen-settings > small { + color: var(--muted); + font-size: 16px; + grid-column: 1 / -1; +} + +.android-display-list { + display: grid; + gap: 7px; + grid-column: 1 / -1; +} + +.android-display-list span { + background: #090b10; + color: var(--muted); + display: flex; + font-size: 16px; + gap: 10px; + padding: 8px 10px; +} + +.android-display-list strong { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 7px; +} + +.dual-screen-actions { + display: grid; + gap: 9px; + min-width: 260px; +} + +.dual-screen-actions button, +.dual-bottom-status button, +.dual-bottom-display button, +.dual-top-game-toolbar button { + background: #242630; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + min-height: 44px; + outline: 2px solid #4b4855; + padding: 8px 14px; +} + +.dual-screen-actions button.selected, +.dual-bottom-status.connected { + outline-color: var(--green); +} + +.binding-capture, +.controller-keyboard-backdrop { + align-items: center; + background: rgba(5, 6, 9, 0.88); + display: flex; + inset: 0; + justify-content: center; + position: fixed; + z-index: 100; +} + +.binding-capture > div { + background: var(--panel); + border: 3px solid #090a0d; + box-shadow: 8px 8px 0 #050609; + max-width: 480px; + outline: 2px solid var(--gold); + padding: 30px; + text-align: center; + width: calc(100% - 32px); +} + +.binding-capture p:not(.eyebrow) { + color: var(--muted); + font-size: 21px; + margin: 18px 0; +} + +.binding-capture button, +.controller-keyboard button { + background: #242630; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + outline: 2px solid #4b4855; +} + +.binding-capture button { + min-height: 44px; + padding: 8px 24px; +} + +.controller-keyboard { + background: var(--panel); + border: 3px solid #090a0d; + box-shadow: 8px 8px 0 #050609; + max-width: 820px; + outline: 2px solid var(--gold); + padding: 20px; + width: calc(100% - 28px); +} + +.controller-keyboard-heading { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 14px; +} + +.controller-keyboard-heading strong { + display: block; + font-size: 21px; + max-width: 650px; + min-height: 25px; + overflow: hidden; +} + +.controller-keyboard-heading button { + min-height: 42px; + padding: 8px 18px; +} + +.controller-keyboard-grid { + display: grid; + gap: 7px; + grid-template-columns: repeat(10, 1fr); +} + +.controller-keyboard-grid button { + font-family: 'Press Start 2P', monospace; + font-size: 9px; + min-height: 43px; +} + +.controller-keyboard-actions { + display: grid; + gap: 8px; + grid-template-columns: 1fr 2fr 1.5fr 1fr; + margin-top: 10px; +} + +.controller-keyboard-actions button { + min-height: 45px; +} + +.controller-keyboard-actions button.active { + color: var(--gold); + outline-color: var(--gold); +} + +.target-controls { + align-items: center; + color: var(--muted); + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 6px; + gap: 18px; + justify-content: flex-end; + margin: 8px 0 10px; +} + +.target-controls span { + align-items: center; + display: inline-flex; + gap: 5px; +} + +.dual-bottom-shell { + width: min(1220px, calc(100% - 20px)); +} + +.dual-bottom-shell > .topbar { + padding-bottom: 14px; + padding-top: 14px; +} + +.dual-bottom-status { + align-items: center; + background: var(--panel); + border: 3px solid #0c0d11; + box-shadow: 7px 7px 0 #08090c; + display: grid; + gap: 20px; + grid-template-columns: auto minmax(220px, 1fr) auto; + margin-top: 18px; + outline: 2px solid var(--red); + padding: 14px 18px; +} + +.dual-bottom-status strong { + color: var(--red-bright); + font-family: 'Press Start 2P', monospace; + font-size: 9px; +} + +.dual-bottom-status.connected strong { + color: #76d39a; +} + +.dual-bottom-fallback { + align-items: center; + display: grid; + gap: 10px; + grid-template-columns: auto minmax(150px, 1fr) auto; +} + +.dual-bottom-fallback > span, +.dual-bottom-fallback > small { + color: var(--muted); +} + +.dual-bottom-actions { + margin-top: 18px; + min-height: 440px; + padding: 28px; +} + +.dual-bottom-actions .active-target-card { + min-width: 390px; +} + +.dual-bottom-actions .mana-wrap { + width: min(520px, 48%); +} + +.dual-bottom-actions .spell { + min-height: 190px; +} + +.dual-bottom-actions .spell-icon { + height: 66px; + margin-top: 25px; + width: 66px; +} + +.dual-bottom-actions .spell strong { + font-size: 19px; +} + +.dual-bottom-actions .spell small { + font-size: 17px; +} + +.dual-top-shell { + background: + linear-gradient(rgba(8, 9, 12, 0.91), rgba(8, 9, 12, 0.91)), + repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px); + color: var(--ink); + display: grid; + gap: 16px; + grid-template-rows: auto auto 1fr auto; + min-height: 100vh; + padding: 18px; +} + +.dual-top-header, +.dual-top-enemy, +.dual-top-party, +.dual-top-log, +.dual-top-waiting > section { + background: var(--panel); + border: 3px solid #0c0d11; + box-shadow: 7px 7px 0 #08090c; + outline: 2px solid var(--edge); +} + +.dual-top-header { + align-items: center; + display: flex; + justify-content: space-between; + padding: 14px 20px; +} + +.dual-top-header h1 { + font-size: clamp(17px, 2.1vw, 28px); +} + +.dual-top-progress { + align-items: center; + display: flex; + gap: 18px; +} + +.dual-top-progress > span { + color: var(--muted); + font-family: 'Press Start 2P', monospace; + font-size: 8px; +} + +.dual-top-enemy { + align-items: center; + display: flex; + gap: 14px; + padding: 10px 14px; +} + +.dual-top-enemy .enemy-portrait { + flex-basis: 84px; + height: 84px; +} + +.dual-top-enemy .bar { + height: 25px; +} + +.dual-top-party { + min-height: 0; + padding: 10px 14px; +} + +.dual-top-party-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.dual-top-party-grid.raid { + grid-template-rows: repeat(2, minmax(0, 1fr)); +} + +.dual-top-member { + background: var(--panel-light); + border: 2px solid #0a0b0e; + min-height: 120px; + outline: 2px solid #3a3944; + padding: 14px; +} + +.dual-top-member.selected { + background: #29291f; + outline: 3px solid var(--gold); +} + +.dual-top-member.dead { + filter: grayscale(1); + opacity: 0.48; +} + +.dual-top-member .member-header strong { + font-size: 20px; +} + +.dual-top-member .member-header small { + font-size: 17px; +} + +.dual-top-member .bar { + height: 22px; + margin-top: 14px; +} + +.dual-top-member .member-target-key { + display: inline-flex; + align-items: center; + justify-content: center; + background: #0a0b0e; + border: 2px solid #3a3944; + border-radius: 6px; + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 14px; + font-weight: 600; + margin-top: 10px; + padding: 4px 10px; + text-transform: uppercase; + white-space: nowrap; + gap: 4px; +} + +.dual-top-member .member-target-key kbd { + font-family: 'Press Start 2P', monospace; + font-size: inherit; +} + +.dual-top-member .member-target-key svg { + display: inline-block; + vertical-align: middle; +} + +.dual-top-log { + display: flex; + gap: 14px; + min-height: 36px; + overflow: hidden; + padding: 6px 14px; +} + +.dual-top-log span { + color: var(--muted); + flex: 1; + font-size: 17px; +} + +.dual-top-log .heal { + color: #78d79d; +} + +.dual-top-log .danger { + color: #ff8190; +} + +.dual-top-log .loot { + color: var(--gold); +} + +.dual-top-waiting { + align-items: center; + display: flex; + justify-content: center; +} + +.dual-top-waiting > section { + padding: 40px; + text-align: center; +} + +.dual-top-waiting p:not(.eyebrow) { + color: var(--muted); + font-size: 22px; + margin: 15px 0; +} + +.game-shell.dual-top-game-shell { + height: 100dvh; + margin: 0 auto; + padding: 10px; + width: 100%; +} + +.dual-top-game-toolbar { + align-items: center; + color: var(--muted); + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + gap: 12px; + justify-content: flex-end; + min-height: 42px; +} + +.dual-top-game-toolbar > span { + margin-right: auto; +} + +.dual-top-game-toolbar button { + min-height: 34px; +} + +.dual-top-main { + display: grid; + gap: 10px; + height: calc(100dvh - 20px); + grid-template-rows: auto 1fr auto; + min-height: 0; +} + +.dual-top-main .dual-top-enemy, +.dual-top-main .dual-top-party, +.dual-top-main .dual-top-log { + margin: 0; + width: 100%; +} + +.dual-top-main .dual-top-party-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dual-top-main .dual-top-party-grid.raid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.dual-top-main .dual-top-member { + color: var(--ink); + cursor: pointer; + text-align: left; + width: 100%; +} + +.dual-top-main .dual-top-member:hover { + outline-color: var(--gold); +} + +.dual-bottom-display { + background: + linear-gradient(rgba(8, 9, 12, 0.91), rgba(8, 9, 12, 0.91)), + repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px); + color: var(--ink); + display: grid; + gap: 10px; + grid-template-rows: auto auto auto 1fr; + min-height: 100vh; + padding: 10px; +} + +.dual-controls-header, +.dual-controls-resource, +.dual-controls-targets, +.dual-controls-spells, +.dual-bottom-waiting > section { + background: var(--panel); + border: 3px solid #0c0d11; + outline: 2px solid var(--edge); +} + +.dual-controls-header { + align-items: center; + display: flex; + justify-content: space-between; + min-height: 70px; + padding: 10px 16px; +} + +.dual-controls-header h1 { + font-size: clamp(14px, 2.2vw, 23px); +} + +.dual-controls-progress { + color: var(--muted); + font-family: 'Press Start 2P', monospace; + font-size: 7px; +} + +.dual-controls-resource { + align-items: center; + display: grid; + gap: 20px; + grid-template-columns: minmax(180px, 0.7fr) minmax(260px, 1.3fr); + padding: 12px 16px; +} + +.dual-controls-resource strong { + font-size: 20px; +} + +.dual-controls-mana > span { + color: var(--muted); + display: block; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + margin-bottom: 6px; + text-align: right; +} + +.dual-controls-mana .bar { + height: 23px; +} + +.dual-controls-targets { + display: grid; + gap: 8px; + grid-template-columns: 1fr 1fr; + padding: 8px; +} + +.dual-controls-targets button { + align-items: center; + display: inline-flex; + gap: 6px; + justify-content: center; + min-height: 42px; +} + +.dual-controls-targets.direct { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dual-controls-targets.direct button { + font-size: 15px; + min-height: 34px; +} + +.dual-controls-spells { + display: grid; + gap: 9px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + min-height: 0; + padding: 10px; +} + +.dual-controls-spells .spell { + min-height: 0; +} + +.dual-controls-spells .spell-icon { + height: 58px; + margin-top: 21px; + width: 58px; +} + +.dual-controls-spells .spell strong { + font-size: 18px; +} + +.dual-controls-spells .spell small { + font-size: 16px; +} + +@media (max-width: 700px), (max-height: 640px) { + .dual-bottom-display { + gap: 6px; + min-height: 100dvh; + overflow: hidden; + padding: 6px; + } + + .dual-controls-header, + .dual-controls-resource, + .dual-controls-targets, + .dual-controls-spells { + border-width: 2px; + width: 100%; + } + + .dual-controls-header { + min-height: 42px; + padding: 5px 8px; + } + + .dual-controls-header .eyebrow { + font-size: 6px; + margin-bottom: 3px; + } + + .dual-controls-header h1 { + font-size: 13px; + line-height: 1.2; + } + + .dual-controls-progress { + font-size: 6px; + } + + .dual-controls-resource { + gap: 8px; + grid-template-columns: minmax(110px, 0.8fr) minmax(150px, 1.2fr); + padding: 6px 8px; + } + + .dual-controls-resource .eyebrow { + font-size: 6px; + margin-bottom: 3px; + } + + .dual-controls-resource strong { + font-size: 16px; + } + + .dual-controls-mana > span { + font-size: 6px; + } + + .dual-controls-mana .bar { + height: 16px; + } + + .dual-controls-targets { + gap: 4px; + padding: 4px; + } + + .dual-controls-targets.direct { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .dual-controls-targets button, + .dual-controls-targets.direct button { + font-size: 12px; + min-height: 30px; + padding: 4px 5px; + } + + .dual-controls-spells { + gap: 5px; + padding: 5px; + } + + .dual-controls-spells .spell { + min-height: 0; + padding: 5px 3px; + } + + .dual-controls-spells .spell kbd { + font-size: 8px; + padding: 1px 4px; + } + + .dual-controls-spells .spell-icon { + font-size: 13px; + height: 32px; + margin-top: 16px; + width: 32px; + } + + .dual-controls-spells .spell strong { + font-size: 13px; + line-height: 1.1; + } + + .dual-controls-spells .spell small { + font-size: 12px; + } +} + +.dual-bottom-waiting { + align-items: center; + display: flex; + justify-content: center; +} + +.dual-bottom-waiting > section { + padding: 36px; + text-align: center; +} + +.dual-bottom-waiting p:not(.eyebrow) { + color: var(--muted); + font-size: 21px; + margin-top: 14px; +} + +.game-shell { + width: min(1180px, calc(100% - 28px)); + margin: 22px auto; + position: relative; +} + +.auth-shell { + align-items: center; + display: flex; + min-height: 100vh; + padding: 28px; +} + +.auth-panel { + display: grid; + gap: 24px; + grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr); + margin: 0 auto; + max-width: 980px; + width: 100%; +} + +.auth-brand, +.auth-card { + background: var(--panel); + border: 2px solid #090a0d; + min-width: 0; + outline: 2px solid #41404a; + padding: 28px; +} + +.auth-brand { + align-items: flex-start; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 430px; +} + +.auth-brand h1 { + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: clamp(25px, 4vw, 45px); + line-height: 1.3; + margin: 10px 0 22px; +} + +.auth-brand > p:last-child { + color: var(--muted); + font-size: 24px; + line-height: 1.35; + max-width: 580px; +} + +.auth-tabs { + display: grid; + gap: 7px; + grid-template-columns: 1fr 1fr; +} + +.auth-tabs button { + background: #17181e; + border: 2px solid #090a0d; + color: var(--muted); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + min-height: 48px; + outline: 2px solid #3e3d47; +} + +.auth-tabs button.selected { + color: var(--gold); + outline-color: var(--gold); +} + +.auth-card form { + display: grid; + gap: 16px; + margin-top: 24px; +} + +.auth-card label { + color: var(--muted); + display: grid; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + gap: 8px; + text-transform: uppercase; +} + +.auth-card input { + background: #111217; + border: 2px solid #090a0d; + box-sizing: border-box; + color: var(--ink); + font-family: inherit; + font-size: 20px; + outline: 2px solid #3e3d47; + padding: 11px; + width: 100%; +} + +.auth-card input:focus { + outline-color: var(--gold); +} + +.auth-message { + color: var(--muted); + font-size: 16px; + line-height: 1.25; + margin-top: 18px; + min-height: 40px; +} + +.auth-message.error { + color: #ff8190; +} + +.offline-divider { + align-items: center; + color: var(--muted); + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + gap: 10px; + margin: 20px 0; + text-transform: uppercase; +} + +.offline-divider::before, +.offline-divider::after { + background: #3e3d47; + content: ''; + flex: 1; + height: 2px; +} + +.offline-entry { + background: #111217; + border: 2px solid #090a0d; + display: grid; + gap: 13px; + outline: 2px solid #3e3d47; + padding: 16px; +} + +.offline-entry h2 { + color: var(--ink); + font-size: 11px; +} + +.offline-entry p:not(.eyebrow) { + color: var(--muted); + font-size: 16px; + line-height: 1.2; + margin-top: 6px; +} + +.offline-resume-button, +.offline-new-button { + cursor: pointer; + min-height: 42px; +} + +.offline-resume-button { + background: var(--gold); + border: 2px solid #090a0d; + color: #19150e; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + outline: 2px solid #816630; +} + +.offline-new-button { + border: 2px solid #3e3d47; + width: 100%; +} + +.logout-button { + background: transparent; + border: 0; + color: var(--muted); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 6px; + padding: 3px 0; +} + +.logout-button:hover { + color: var(--gold); +} + +.topbar, +.enemy-card, +.party-panel, +.combat-log, +.action-panel, +.menu-screen, +.content-screen, +.message-panel { + border: 3px solid #0c0d11; + outline: 2px solid var(--edge); + background: var(--panel); + box-shadow: 7px 7px 0 #08090c; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 18px; +} + +.app-header { + min-height: 68px; +} + +.brand-button { + background: none; + border: 0; + color: var(--ink); + cursor: pointer; + padding: 0; + text-align: left; +} + +.brand-button .eyebrow { + display: block; +} + +.brand-button strong { + font-family: 'Press Start 2P', monospace; + font-size: clamp(10px, 1.3vw, 14px); +} + +.character-summary { + align-items: center; + display: flex; + gap: 12px; + text-align: right; +} + +.character-summary span, +.character-summary small { + font-family: 'Press Start 2P', monospace; + font-size: 7px; +} + +.character-summary strong { + color: var(--ink); + font-size: 19px; +} + +.character-summary .mode-badge { + color: #8fc7ff; + margin-top: 2px; +} + +.character-summary .mode-offline { + color: var(--gold); +} + +.header-xp { + background: #090a0d; + height: 5px; + width: 100px; +} + +.header-xp span, +.experience-bar span { + background: var(--purple); + box-shadow: inset 0 3px #b08bdf; + display: block; + height: 100%; +} + +h1, +h2, +p { + margin: 0; +} + +h1, +h2 { + color: var(--ink); + font-family: 'Press Start 2P', monospace; + line-height: 1.45; +} + +h1 { + font-size: clamp(18px, 3vw, 28px); +} + +h2 { + font-size: 14px; +} + +.eyebrow { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 8px; + letter-spacing: 1px; + margin-bottom: 8px; + text-transform: uppercase; +} + +.menu-screen, +.content-screen, +.message-panel { + margin-top: 18px; + min-height: 0; + padding: 28px; +} + +.message-panel { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; +} + +.message-panel > p:last-child { + color: var(--muted); + font-size: 22px; + margin-top: 16px; +} + +.menu-intro { + border-bottom: 2px solid #34343d; + padding: 20px 4px 28px; + text-align: center; +} + +.menu-intro > p:last-child { + color: var(--muted); + font-size: 23px; + margin: 15px auto 0; + max-width: 700px; +} + +.progress-summary { + align-items: center; + display: grid; + gap: 9px; + grid-template-columns: auto minmax(150px, 310px) auto; + justify-content: center; + margin-top: 20px; +} + +.progress-summary > span, +.progress-summary > small { + color: var(--muted); + font-family: 'Press Start 2P', monospace; + font-size: 7px; +} + +.experience-bar { + height: 12px; +} + +.main-menu-grid { + display: grid; + gap: 15px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0 auto; + max-width: 930px; +} + +.roguelike-mode-grid { + display: grid; + gap: 15px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 16px; +} + +.roguelike-variant-row { + display: flex; + gap: 10px; + margin-bottom: 16px; +} + +.roguelike-option-panel { + align-items: center; + display: flex; + gap: 16px; + justify-content: space-between; +} + +.roguelike-timing-row { + display: flex; + gap: 10px; +} + +.roguelike-timing-row .text-button.active { + background: var(--gold); + color: #111216; +} + +.pvp-queue-panel { + justify-content: space-between; + margin-top: 16px; +} + +.pvp-queue-panel .text-button { + white-space: nowrap; +} + +.menu-card { + align-items: center; + background: var(--panel-light); + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: flex; + gap: 16px; + min-height: 105px; + outline: 2px solid #42414c; + padding: 16px; + text-align: left; +} + +.menu-card:first-child { + grid-column: 1 / -1; +} + +.menu-card:hover { + outline-color: var(--gold); + transform: translateY(-2px); +} + +.menu-card > span, +.class-portrait { + align-items: center; + background: #13141a; + border: 2px solid var(--gold); + color: var(--gold); + display: flex; + flex: 0 0 58px; + font-family: 'Press Start 2P', monospace; + font-size: 19px; + height: 58px; + justify-content: center; +} + +.menu-card strong, +.menu-card small { + display: block; +} + +.menu-card strong { + font-family: 'Press Start 2P', monospace; + font-size: 11px; + margin-bottom: 9px; +} + +.menu-card small { + color: var(--muted); + font-size: 18px; + line-height: 1.05; +} + +.screen-heading { + align-items: center; + border-bottom: 2px solid #34343d; + display: flex; + justify-content: space-between; + padding-bottom: 14px; +} + +.back-button, +.text-button { + background: #15161c; + border: 2px solid #090a0d; + color: var(--muted); + cursor: pointer; + outline: 2px solid #464550; + padding: 9px 13px; +} + +.back-button:hover, +.text-button:hover { + color: var(--ink); + outline-color: var(--gold); +} + +.combat-header-actions { + align-items: center; + display: flex; + gap: 16px; +} + +.dungeon-card { + align-items: center; + background: var(--panel-light); + border: 2px solid #090a0d; + display: grid; + gap: 20px; + grid-template-columns: 112px 1fr minmax(180px, 240px) auto; + margin-top: 16px; + outline: 2px solid #494754; + padding: 19px; +} + +.dungeon-art { + align-items: center; + background: linear-gradient(#632831, #24151c); + border: 3px solid #0a0b0e; + color: #ef7b66; + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 20px; + height: 92px; + justify-content: center; + outline: 2px solid #8d3e45; +} + +.dungeon-art.raid-art { + background: linear-gradient(#713b16, #2d160d); + color: #ffc15b; + outline-color: #a85d20; +} + +.dungeon-card h2 { + font-size: 16px; +} + +.dungeon-card p:not(.eyebrow) { + color: var(--muted); + font-size: 18px; + margin-top: 6px; +} + +.activity-select, +.loot-toolbar label { + display: grid; + gap: 8px; +} + +.activity-select > span, +.loot-toolbar span { + color: var(--muted); + font-family: 'Press Start 2P', monospace; + font-size: 7px; + text-transform: uppercase; +} + +.activity-select select, +.loot-toolbar select { + background: #15161c; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + min-height: 42px; + outline: 2px solid #41404a; + padding: 0 12px; + width: 100%; +} + +.difficulty-section { + background: #1c1e25; + border: 2px solid #0a0b0e; + margin-top: 14px; + outline: 2px solid #3e3d47; + padding: 16px; +} + +.compact-difficulty-section { + display: grid; + gap: 12px; +} + +.difficulty-select-row { + align-items: center; + display: flex; + gap: 18px; + justify-content: space-between; +} + +.difficulty-select-row label { + align-items: center; + display: flex; + gap: 9px; +} + +.difficulty-select-row label > span { + color: var(--muted); + font-family: 'Press Start 2P', monospace; + font-size: 7px; + text-transform: uppercase; +} + +.difficulty-select-row select { + background: #15161c; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + min-height: 42px; + outline: 2px solid #41404a; + padding: 0 12px; +} + +.difficulty-summary { + align-items: center; + background: var(--panel-light); + border: 2px solid #090a0d; + display: grid; + gap: 12px; + grid-template-columns: minmax(180px, 1fr) minmax(0, 2fr); + outline: 2px solid #41404a; + padding: 11px 13px; +} + +.difficulty-summary.locked { + opacity: 0.62; +} + +.difficulty-summary strong, +.difficulty-summary small { + display: block; +} + +.difficulty-summary strong { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 9px; +} + +.difficulty-summary small { + color: var(--muted); + font-size: 15px; + margin-top: 4px; +} + +.difficulty-summary dl { + display: grid; + gap: 8px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0; +} + +.difficulty-summary dl > div { + background: #15161c; + padding: 7px 8px; +} + +.difficulty-summary dt, +.difficulty-summary dd { + margin: 0; +} + +.difficulty-summary dt { + color: var(--muted); + font-size: 13px; +} + +.difficulty-summary dd { + color: var(--gold); + font-size: 14px; +} + +.difficulty-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(5, minmax(0, 1fr)); + margin-top: 14px; +} + +.difficulty-grid > button { + background: var(--panel-light); + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + min-height: 230px; + outline: 2px solid #41404a; + padding: 11px; + text-align: left; +} + +.difficulty-grid > button:hover, +.difficulty-grid > button.selected { + outline-color: var(--gold); + transform: translateY(-2px); +} + +.difficulty-grid > button.locked { + filter: grayscale(0.65); + opacity: 0.62; +} + +.difficulty-grid > button.selected.locked { + outline-color: #8f6368; +} + +.difficulty-title { + align-items: center; + display: flex; + gap: 8px; +} + +.difficulty-title > span { + align-items: center; + background: #15161c; + color: var(--gold); + display: flex; + flex: 0 0 32px; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + height: 32px; + justify-content: center; +} + +.difficulty-title strong, +.difficulty-title small { + display: block; +} + +.difficulty-title strong { + font-family: 'Press Start 2P', monospace; + font-size: 7px; +} + +.difficulty-title small { + color: var(--muted); + font-size: 14px; + margin-top: 3px; +} + +.difficulty-grid > button > p { + color: var(--muted); + font-size: 16px; + line-height: 1; + min-height: 50px; + margin-top: 12px; +} + +.difficulty-grid dl { + margin: 10px 0 0; +} + +.difficulty-grid dl > div { + border-top: 1px solid #3c3c46; + display: flex; + justify-content: space-between; + padding: 4px 0; +} + +.difficulty-grid dt { + color: var(--muted); + font-size: 14px; +} + +.difficulty-grid dd { + color: var(--gold); + font-size: 14px; + margin: 0; +} + +.loot-preview-section { + background: #1c1e25; + border: 2px solid #0a0b0e; + margin-top: 14px; + outline: 2px solid #3e3d47; + padding: 16px; +} + +.toggle-heading { + gap: 18px; +} + +.section-note { + color: var(--muted); + font-size: 16px; + margin-top: 10px; +} + +.loot-toolbar { + display: flex; + justify-content: flex-end; + margin-top: 12px; +} + +.loot-preview-grid { + display: grid; + gap: 11px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 14px; +} + +.loot-preview-grid > article { + background: var(--panel-light); + border: 2px solid #090a0d; + outline: 2px solid #41404a; + padding: 11px; +} + +.loot-encounter-title { + align-items: center; + border-bottom: 1px solid #3b3b45; + display: flex; + gap: 9px; + padding-bottom: 9px; +} + +.loot-encounter-title > span { + align-items: center; + background: #15161c; + color: var(--gold); + display: flex; + flex: 0 0 31px; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + height: 31px; + justify-content: center; +} + +.loot-boss-icon { + background: #15161c; + border: 2px solid #090a0d; + display: block; + flex: 0 0 35px; + height: 35px; + object-fit: cover; + outline: 1px solid #41404a; + width: 35px; +} + +.loot-encounter-title strong, +.loot-encounter-title small { + display: block; +} + +.loot-encounter-title strong { + font-family: 'Press Start 2P', monospace; + font-size: 7px; + line-height: 1.4; +} + +.loot-encounter-title small { + color: var(--muted); + margin-top: 3px; +} + +.loot-items { + display: grid; + gap: 7px; + margin-top: 9px; +} + +.loot-items > div { + align-items: center; + background: #1a1b21; + border-left: 3px solid var(--rarity-color, #a8a3ad); + display: grid; + gap: 7px; + grid-template-columns: 29px 1fr auto; + min-height: 51px; + padding: 6px; +} + +.loot-items > div > span { + align-items: center; + color: var(--rarity-color, var(--ink)); + display: flex; + font-family: 'Press Start 2P', monospace; + justify-content: center; +} + +.loot-items strong, +.loot-items small { + display: block; +} + +.loot-items strong { + color: var(--rarity-color, var(--ink)); + font-size: 14px; +} + +.loot-items small, +.loot-items i { + color: var(--muted); + font-size: 13px; +} + +.loot-items i { + font-style: normal; +} + +.leaderboard-section { + background: #1c1e25; + border: 2px solid #0a0b0e; + margin-top: 14px; + outline: 2px solid #3e3d47; + padding: 16px; +} + +.leaderboard-table { + background: var(--panel-light); + border: 2px solid #090a0d; + margin-top: 14px; + outline: 2px solid #41404a; +} + +.leaderboard-header, +.leaderboard-row { + align-items: center; + display: grid; + gap: 10px; + grid-template-columns: 55px minmax(110px, 1.2fr) minmax(110px, 1fr) 60px 85px 80px 60px; + padding: 9px 12px; +} + +.leaderboard-header { + background: #15161c; + color: var(--muted); + font-family: 'Press Start 2P', monospace; + font-size: 7px; +} + +.leaderboard-row { + border-top: 1px solid #3a3a44; + color: var(--ink); + font-size: 16px; +} + +.leaderboard-row:nth-child(2) { + background: #30291a; +} + +.leaderboard-row > strong { + color: var(--gold); +} + +.leaderboard-empty { + color: var(--muted); + padding: 22px; + text-align: center; +} + +.leaderboard-tabs { + display: flex; + gap: 6px; + margin-top: 12px; +} + +.leaderboard-tab { + background: #15161c; + border: 2px solid #090a0d; + color: var(--muted); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 6px; + outline: 2px solid #3e3d47; + padding: 8px 12px; + text-transform: uppercase; +} + +.leaderboard-tab.active { + color: var(--gold); + outline-color: var(--gold); +} + +.leaderboard-tab:hover { + color: var(--ink); +} + +.tag-row { + display: flex; + gap: 7px; + margin-top: 10px; + flex-wrap: wrap; +} + +.tag-row span { + background: #15161c; + color: var(--gold); + font-size: 15px; + padding: 3px 7px; +} + +.part-buttons { + display: flex; + gap: 8px; +} + +.part-buttons .primary-button { + white-space: nowrap; +} + +.part-buttons .primary-button.selected-part { + outline-color: #fff; + background: #f0cb79; +} + +.part-buttons .primary-button.locked { + filter: grayscale(0.65); + opacity: 0.62; + cursor: not-allowed; +} + +.primary-button { + background: var(--gold); + border: 2px solid #08090c; + color: #19150e; + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + outline: 2px solid #816630; + padding: 13px 17px; +} + +.primary-button:hover:not(:disabled) { + background: #f0cb79; + transform: translateY(-1px); +} + +.primary-button:disabled { + cursor: wait; + opacity: 0.6; +} + +.placeholder-panel { + align-items: center; + display: flex; + flex-direction: column; + min-height: 400px; + justify-content: center; + text-align: center; +} + +.placeholder-panel > p { + color: var(--muted); + font-size: 24px; + max-width: 620px; +} + +.placeholder-runes { + color: #4a4855; + font-family: 'Press Start 2P', monospace; + font-size: 39px; + margin-bottom: 25px; + word-spacing: 20px; +} + +.talent-preview { + align-items: center; + background: var(--panel-light); + border: 2px solid #090a0d; + display: flex; + gap: 19px; + max-width: 660px; + outline: 2px solid #494754; + padding: 22px; + text-align: left; +} + +.talent-preview > span { + align-items: center; + background: #4a3720; + color: var(--gold); + display: flex; + flex: 0 0 64px; + font-family: 'Press Start 2P', monospace; + height: 64px; + justify-content: center; +} + +.talent-preview strong { + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 10px; +} + +.talent-preview p { + color: var(--muted); + font-size: 20px; + margin-top: 8px; +} + +.talent-screen { + padding-bottom: 20px; +} + +.talent-toolbar { + align-items: center; + background: #20222a; + border: 2px solid #0a0b0e; + display: flex; + justify-content: space-between; + margin-top: 20px; + outline: 2px solid #42414c; + padding: 14px 17px; +} + +.talent-class-summary { + align-items: center; + display: flex; + gap: 13px; +} + +.talent-class-summary > span { + align-items: center; + background: #121319; + border: 2px solid; + display: flex; + flex: 0 0 48px; + font-family: 'Press Start 2P', monospace; + height: 48px; + justify-content: center; +} + +.talent-points { + display: grid; + text-align: right; +} + +.talent-points strong { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 22px; +} + +.talent-points span { + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 7px; + margin-top: 4px; +} + +.talent-points small { + color: var(--muted); + margin-top: 3px; +} + +.talent-tree { + margin-top: 17px; +} + +.talent-tier { + align-items: stretch; + border-bottom: 1px solid #393943; + display: grid; + gap: 15px; + grid-template-columns: 115px 1fr; + padding: 16px 0; +} + +.tier-label { + align-items: flex-start; + border-right: 2px solid #353640; + display: flex; + flex-direction: column; + justify-content: center; + padding-right: 12px; +} + +.tier-label span { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 8px; +} + +.tier-label small { + color: var(--muted); + font-size: 15px; + line-height: 1; + margin-top: 8px; +} + +.tier-talents { + display: grid; + gap: 11px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.talent-node { + background: var(--panel-light); + border: 2px solid #090a0d; + display: flex; + flex-direction: column; + min-height: 190px; + outline: 2px solid #41404a; + padding: 11px; +} + +.talent-node.available { + outline-color: #77623b; +} + +.talent-node.invested { + background: #26271f; + outline-color: var(--gold); +} + +.talent-node.locked:not(.invested) { + opacity: 0.62; +} + +.talent-node-header { + align-items: center; + display: flex; + gap: 9px; +} + +.talent-node-header > span { + align-items: center; + background: #15161c; + border: 1px solid #55515f; + color: var(--gold); + display: flex; + flex: 0 0 37px; + font-family: 'Press Start 2P', monospace; + height: 37px; + justify-content: center; +} + +.talent-node-header strong, +.talent-node-header small { + display: block; +} + +.talent-node-header strong { + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 7px; + line-height: 1.45; +} + +.talent-node-header small { + color: var(--gold); + margin-top: 2px; +} + +.talent-node > p { + color: var(--muted); + flex: 1; + font-size: 16px; + line-height: 1; + margin-top: 10px; +} + +.rank-pips { + display: flex; + gap: 4px; + margin: 10px 0; +} + +.rank-pips i { + background: #111218; + border: 1px solid #484650; + display: block; + height: 6px; + width: 18px; +} + +.rank-pips i.filled { + background: var(--gold); + border-color: #f0ce82; +} + +.talent-node > button { + background: #17181e; + border: 1px solid #08090c; + color: var(--gold); + cursor: pointer; + min-height: 30px; + outline: 1px solid #514d40; + padding: 5px; +} + +.talent-node > button:disabled { + color: #77737e; + cursor: not-allowed; + font-size: 14px; + outline-color: #3b3a43; +} + +.talent-footer { + align-items: center; + display: flex; + justify-content: space-between; + padding-top: 18px; +} + +.talent-footer > span { + color: var(--muted); +} + +.talent-footer .text-button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.gear-summary { + align-items: center; + background: #20222a; + border: 2px solid #0a0b0e; + display: grid; + gap: 14px; + grid-template-columns: 1.6fr repeat(3, 1fr); + margin-top: 20px; + outline: 2px solid #42414c; + padding: 14px 17px; +} + +.gear-character { + align-items: center; + display: flex; + gap: 12px; +} + +.gear-character > span { + align-items: center; + background: #121319; + border: 2px solid; + display: flex; + flex: 0 0 48px; + font-family: 'Press Start 2P', monospace; + height: 48px; + justify-content: center; +} + +.gear-stat { + border-left: 1px solid #464650; + display: grid; + padding-left: 14px; +} + +.gear-stat strong { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 15px; +} + +.gear-stat span { + color: var(--muted); + font-size: 15px; + margin-top: 4px; +} + +.equipment-layout { + display: grid; + gap: 17px; + grid-template-columns: minmax(0, 1.15fr) minmax(300px, 0.85fr); + margin-top: 18px; +} + +.equipped-panel, +.inventory-panel, +.crafting-panel, +.set-bonus-panel { + background: #1c1e25; + border: 2px solid #0a0b0e; + outline: 2px solid #3e3d47; + padding: 15px; +} + +.equipment-tabs { + display: flex; + gap: 8px; + margin-top: 18px; +} + +.equipment-tab { + background: var(--panel-light); + border: 2px solid #090a0d; + color: var(--muted); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + min-height: 44px; + outline: 2px solid #41404a; + padding: 8px 16px; + text-transform: uppercase; +} + +.equipment-tab.active, +.equipment-tab:hover { + color: var(--gold); + outline-color: var(--gold); +} + +.equipment-heading { + align-items: center; + display: flex; + justify-content: space-between; +} + +.equipment-heading > span { + color: var(--muted); + font-family: 'Press Start 2P', monospace; + font-size: 7px; +} + +.equipment-slots { + display: grid; + gap: 8px; + margin-top: 13px; +} + +.equipment-slots > button { + align-items: center; + background: var(--panel-light); + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: grid; + gap: 9px; + grid-template-columns: 38px 1fr auto; + min-height: 62px; + outline: 2px solid #41404a; + padding: 8px; + text-align: left; +} + +.equipment-slots > button:hover { + outline-color: var(--gold); +} + +.equipment-slots > button.selected-slot { + background: #29291f; + outline-color: var(--gold); +} + +.equipment-slots > button > span { + align-items: center; + background: #15161c; + color: var(--gold); + display: flex; + font-family: 'Press Start 2P', monospace; + height: 38px; + justify-content: center; +} + +.equipment-slots > button strong { + font-size: 14px; + color: var(--rarity-color, var(--ink)); +} + +.equipment-slots > button small { + color: var(--muted); + display: block; + margin-top: 3px; +} + +.equipment-slots .item-status i { + color: var(--green); + font-size: 14px; + font-style: normal; +} + +.inventory-list { + display: grid; + gap: 8px; + margin-top: 13px; + max-height: 442px; + overflow-y: auto; + padding: 2px; +} + +.crafting-panel, +.set-bonus-panel { + margin-top: 18px; +} + +.crafting-filter-bar { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.filter-select { + background: #15161c; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + outline: 2px solid #41404a; + padding: 8px 10px; + flex: 1; +} + +.filter-select:focus { + outline-color: var(--gold); +} + +.crafting-layout { + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr) minmax(280px, 0.75fr); + margin-top: 13px; +} + +.crafting-list { + display: grid; + gap: 8px; + max-height: 360px; + overflow-y: auto; + padding: 2px; +} + +.crafting-list > button { + align-items: center; + background: var(--panel-light); + border: 2px solid #090a0d; + border-top-color: var(--rarity-color, #a8a3ad); + color: var(--ink); + cursor: pointer; + display: grid; + gap: 9px; + grid-template-columns: 38px 1fr auto; + min-height: 62px; + outline: 2px solid #41404a; + padding: 8px; + text-align: left; +} + +.crafting-list > button.selected { + outline-color: var(--gold); +} + +.crafting-list > button > span { + align-items: center; + background: #15161c; + color: var(--gold); + display: flex; + font-family: 'Press Start 2P', monospace; + height: 38px; + justify-content: center; +} + +.crafting-list strong, +.crafting-list small { + display: block; +} + +.crafting-list small { + color: var(--muted); + margin-top: 3px; +} + +.crafting-list i { + color: var(--gold); + font-size: 14px; + font-style: normal; + text-align: right; +} + +.crafting-detail { + background: var(--panel-light); + border: 2px solid #090a0d; + border-top-color: var(--rarity-color, #a8a3ad); + display: grid; + gap: 10px; + padding: 10px; +} + +.crafting-detail .item-detail { + padding: 0; + border: 0; +} + +.crafting-components { + display: grid; + gap: 6px; +} + +.crafting-components > div { + align-items: center; + background: #15161c; + display: grid; + gap: 8px; + grid-template-columns: 26px 1fr auto; + padding: 7px; +} + +.crafting-components > div.ready { + color: var(--green); +} + +.crafting-components > div.missing { + color: #e36c79; +} + +.crafting-components span { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + text-align: center; +} + +.crafting-components i { + font-style: normal; +} + +.set-bonus-list { + display: grid; + gap: 8px; + margin-top: 13px; +} + +.set-bonus-list > div { + align-items: center; + background: var(--panel-light); + border: 2px solid #090a0d; + color: var(--muted); + display: grid; + gap: 10px; + grid-template-columns: 110px 1fr auto; + outline: 2px solid #41404a; + padding: 10px; +} + +.set-bonus-list > div.active { + color: var(--ink); + outline-color: var(--gold); +} + +.set-bonus-list strong, +.set-bonus-list i { + color: var(--gold); + font-style: normal; +} + +.inventory-filter-clear { + background: #15161c; + border: 2px solid #090a0d; + color: var(--gold); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + margin-top: 12px; + outline: 2px solid #41404a; + padding: 8px 10px; +} + +.inventory-filter-clear:hover { + outline-color: var(--gold); +} + +.inventory-empty { + color: var(--muted); + padding: 28px 12px; + text-align: center; +} + +.inventory-list > button { + align-items: center; + background: var(--panel-light); + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: grid; + gap: 9px; + grid-template-columns: 38px 1fr auto; + min-height: 62px; + outline: 2px solid #41404a; + padding: 8px; + text-align: left; +} + +.inventory-list > button.selected { + outline-color: var(--gold); +} + +.inventory-list > button > span, +.item-title > span { + align-items: center; + background: #15161c; + color: var(--gold); + display: flex; + font-family: 'Press Start 2P', monospace; + height: 38px; + justify-content: center; +} + +.inventory-list strong, +.inventory-list small { + display: block; +} + +.inventory-list small { + color: var(--muted); + margin-top: 3px; +} + +.inventory-list i { + color: var(--green); + font-size: 14px; + font-style: normal; +} + +.inventory-list .item-status { + display: grid; + gap: 3px; + justify-items: end; +} + +.inventory-list .item-quantity { + color: var(--gold); +} + +.rarity-uncommon { + --rarity-color: #63c882; +} + +.rarity-rare { + --rarity-color: #62a9ed; +} + +.rarity-epic { + --rarity-color: #b584e3; +} + +.rarity-common { + --rarity-color: #a8a3ad; +} + +.equipment-slots > button, +.inventory-list > button, +.item-detail { + border-top-color: var(--rarity-color, #a8a3ad); + border-top-width: 3px; +} + +.inventory-list > button strong, +.item-detail h2 { + color: var(--rarity-color, var(--ink)); +} + +.item-comparison { + align-items: stretch; + background: #1c1e25; + border: 2px solid #0a0b0e; + display: grid; + gap: 12px; + grid-template-columns: 1fr auto 1fr minmax(140px, 0.5fr); + margin-top: 18px; + min-height: 160px; + outline: 2px solid #3e3d47; + padding: 12px; +} + +.item-detail { + background: var(--panel-light); + border: 2px solid #090a0d; + padding: 12px; +} + +.item-title { + align-items: center; + display: flex; + gap: 10px; +} + +.item-title > span { + flex: 0 0 42px; + height: 42px; +} + +.item-title small { + color: var(--muted); +} + +.item-detail > p:not(.eyebrow) { + color: var(--muted); + font-size: 17px; + margin-top: 11px; +} + +.item-detail > p.owned-quantity { + color: var(--gold); +} + +.item-detail ul { + color: var(--ink); + list-style: none; + margin: 11px 0 0; + padding: 0; +} + +.item-detail li { + border-top: 1px solid #3a3a44; + padding: 4px 0; +} + +.empty-comparison { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; +} + +.comparison-arrow { + align-items: center; + color: var(--muted); + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + justify-content: center; +} + +.equip-action { + align-items: stretch; + display: flex; + flex-direction: column; + gap: 8px; + justify-content: flex-end; +} + +.discard-button { + background: #2a1a1d; + border: 2px solid #090a0d; + color: #ef8994; + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + outline: 2px solid #694048; + padding: 11px 8px; +} + +.discard-button:disabled { + cursor: default; + opacity: 0.55; +} + +.breakdown-button { + background: #1a2a1d; + border: 2px solid #090a0d; + color: #89ef94; + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + outline: 2px solid #406948; + padding: 11px 8px; +} + +.breakdown-button:disabled { + cursor: default; + opacity: 0.55; +} + +.component-note { + color: var(--muted); + font-size: 14px; + text-align: center; +} + +.comparison-delta { + display: grid; + gap: 6px; + margin-bottom: 12px; +} + +.comparison-delta span { + background: #15161c; + padding: 5px 7px; + text-align: center; +} + +.comparison-delta .positive { + color: #71d798; +} + +.comparison-delta .negative { + color: #e36c79; +} + +.equipment-footer { + color: var(--muted); + padding-top: 15px; +} + +.customize-tabs { + display: grid; + gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 16px; +} + +.customize-tabs button { + background: #15161c; + border: 2px solid #090a0d; + color: var(--muted); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + min-height: 44px; + outline: 2px solid #41404a; + padding: 8px 10px; +} + +.customize-tabs button.active, +.customize-tabs button:hover { + color: var(--gold); + outline-color: var(--gold); +} + +.embedded-screen .gear-summary, +.embedded-screen .talent-toolbar { + margin-top: 16px; +} + +.customize-layout { + display: grid; + gap: 18px; + grid-template-columns: 250px minmax(0, 1fr); + margin-top: 16px; +} + +.class-picker { + border-right: 2px solid #353640; + padding-right: 16px; +} + +.class-picker > button { + align-items: center; + background: var(--panel-light); + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: flex; + gap: 10px; + margin-bottom: 10px; + outline: 2px solid #41404a; + padding: 10px; + text-align: left; + width: 100%; +} + +.class-picker > button:hover, +.class-picker > button.active { + outline-color: var(--class-color); +} + +.class-picker > button > span { + align-items: center; + background: #111218; + color: var(--class-color); + display: flex; + flex: 0 0 35px; + font-family: 'Press Start 2P', monospace; + height: 35px; + justify-content: center; +} + +.class-picker strong, +.class-picker small { + display: block; +} + +.class-picker strong { + font-family: 'Press Start 2P', monospace; + font-size: 8px; +} + +.class-picker small { + color: var(--muted); + margin-top: 4px; +} + +.class-detail { + align-items: center; + background: #20222a; + border-bottom: 2px solid #3b3b45; + display: flex; + gap: 16px; + padding: 15px; +} + +.class-detail h2 { + font-size: 13px; +} + +.class-detail p:last-child { + color: var(--muted); + font-size: 18px; + margin-top: 5px; +} + +.loadout-heading, +.ability-library-heading, +.save-row { + align-items: center; + display: flex; + justify-content: space-between; + margin-top: 20px; +} + +.loadout-heading > span, +.save-row > span { + color: var(--muted); + font-size: 17px; +} + +.ability-slots { + display: grid; + gap: 8px; + grid-template-columns: repeat(6, minmax(0, 1fr)); + margin-top: 12px; +} + +.ability-slots button { + background: var(--panel-light); + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + min-height: 92px; + outline: 2px solid #44434d; + padding: 8px 3px; + position: relative; +} + +.ability-slots button.selected { + outline-color: var(--gold); + transform: translateY(-2px); +} + +.ability-slots kbd { + color: var(--muted); + left: 5px; + position: absolute; + top: 4px; +} + +.ability-slots span, +.ability-slots strong { + display: block; +} + +.ability-slots span { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 16px; + margin-bottom: 8px; +} + +.ability-slots strong { + font-size: 14px; +} + +.ability-library { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 11px; + max-height: 335px; + overflow-y: auto; + padding: 2px; +} + +.ability-library > button { + align-items: center; + background: var(--panel-light); + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: grid; + gap: 10px; + grid-template-columns: 38px 1fr auto; + min-height: 78px; + outline: 2px solid #44434d; + padding: 9px; + text-align: left; +} + +.ability-library > button:hover:not(:disabled) { + outline-color: var(--gold); +} + +.ability-library > button.equipped { + outline-color: var(--green); +} + +.ability-library > button.locked { + filter: grayscale(1); + opacity: 0.5; +} + +.ability-library > button > span { + align-items: center; + background: #15161c; + color: var(--gold); + display: flex; + font-family: 'Press Start 2P', monospace; + height: 38px; + justify-content: center; +} + +.ability-library strong, +.ability-library small { + display: block; +} + +.ability-library small { + color: var(--muted); + font-size: 15px; + line-height: 1; + margin-top: 4px; +} + +.ability-library i { + color: var(--gold); + font-size: 14px; + font-style: normal; +} + +.run-progress { + display: flex; + gap: 9px; +} + +.run-progress span { + align-items: center; + background: #111218; + border: 2px solid var(--edge); + color: var(--muted); + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 9px; + height: 32px; + justify-content: center; + width: 32px; +} + +.run-progress .active { + border-color: var(--gold); + color: var(--gold); +} + +.run-progress .complete { + background: var(--green); + color: white; +} + +.enemy-card { + align-items: center; + display: flex; + gap: 18px; + margin-top: 18px; + padding: 18px; +} + +.enemy-portrait { + align-items: center; + background: #481e29; + border: 3px solid #0d0e12; + color: var(--red-bright); + display: flex; + flex: 0 0 70px; + font-family: 'Press Start 2P', monospace; + font-size: 25px; + height: 70px; + justify-content: center; + outline: 2px solid var(--red); + text-shadow: 3px 3px #12070a; +} + +.enemy-portrait img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.enemy-info { + flex: 1; +} + +.enemy-info p { + color: var(--muted); + font-size: 21px; + margin-top: 8px; +} + +.bar-label { + color: var(--ink); + display: flex; + font-size: 20px; + justify-content: space-between; +} + +.bar { + background: #08090c; + border: 2px solid #090a0d; + height: 17px; + overflow: hidden; + position: relative; +} + +.bar > span, +.bar > i { + display: block; + height: 100%; + transition: width 180ms linear; +} + +.enemy-health { + margin-top: 5px; +} + +.enemy-health > span { + background: var(--red); + box-shadow: inset 0 5px #cf4b59; +} + +.combat-layout { + display: grid; + gap: 18px; + grid-template-columns: minmax(0, 1.8fr) minmax(260px, 0.8fr); + margin-top: 18px; +} + +.party-panel, +.combat-log { + min-height: 390px; + padding: 18px; +} + +.combat-side-rail { + display: flex; + flex-direction: column; + min-height: 0; +} + +.panel-heading, +.resource-row { + align-items: center; + display: flex; + justify-content: space-between; +} + +.panel-heading > span { + color: var(--muted); + font-family: 'Press Start 2P', monospace; + font-size: 8px; +} + +.party-grid { + display: grid; + gap: 11px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 17px; +} + +.party-member { + background: var(--panel-light); + border: 2px solid #0a0b0e; + color: var(--ink); + cursor: pointer; + min-height: 92px; + outline: 2px solid #3a3944; + padding: 10px; + position: relative; + text-align: left; +} + +.party-member:first-child { + grid-column: 1 / -1; +} + +.raid-party-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.raid-party-grid .party-member:first-child { + grid-column: auto; +} + +.party-member:hover { + outline-color: var(--gold); + transform: translateY(-1px); +} + +.party-member.selected { + background: #29291f; + border-color: #f1ca68; + box-shadow: + inset 0 0 0 2px #6e5727, + 0 0 0 3px #17140c, + 0 0 16px rgba(229, 185, 95, 0.72); + outline: 3px solid var(--gold); + transform: translateY(-2px); +} + +.party-member.selected .member-header strong { + color: #ffe29a; + text-shadow: 2px 2px #171107; +} + +.party-member.selected .member-health { + border-color: #d9b55a; +} + +.target-marker { + align-items: center; + background: var(--gold); + border: 2px solid #0a0b0e; + color: #21180a; + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + gap: 5px; + padding: 5px 7px; + position: absolute; + right: 7px; + text-transform: uppercase; + top: -12px; + z-index: 2; +} + +.target-marker i { + border-bottom: 4px solid transparent; + border-left: 6px solid #21180a; + border-top: 4px solid transparent; + height: 0; + width: 0; +} + +.party-member.dead { + filter: grayscale(1); + opacity: 0.48; +} + +.party-member.dead.selected { + filter: grayscale(0.35); + opacity: 0.72; +} + +.party-member > small { + display: none; +} + +.member-header { + align-items: center; + display: flex; + gap: 8px; +} + +.member-header strong { + flex: 1; + min-width: 0; +} + +.member-header small { + color: var(--muted); + flex: 0 0 auto; + font-size: 15px; + text-align: right; +} + +.party-panel-top { + margin-bottom: 12px; +} + +.role { + align-items: center; + border: 1px solid white; + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + height: 19px; + justify-content: center; + width: 19px; +} + +.role-tank { + background: var(--blue); +} + +.role-healer { + background: var(--green); +} + +.role-damage { + background: var(--red); +} + +.member-health { + margin-top: 8px; +} + +.member-health > span { + background: var(--green); + box-shadow: inset 0 4px #63bf82; +} + +.member-health > i { + background: rgba(85, 174, 231, 0.72); + position: absolute; + right: 0; + top: 0; +} + +.member-effects { + display: flex; + flex-wrap: wrap; + gap: 5px; + min-height: 18px; + margin-top: 6px; +} + +.member-effects span { + font-size: 14px; + padding: 0 5px; +} + +.floating-combat-texts { + inset: 0; + pointer-events: none; + position: absolute; +} + +.floating-heal { + animation: floating-heal 0.9s ease-out forwards; + color: #91f0b0; + font-family: 'Press Start 2P', monospace; + font-size: 9px; + left: 50%; + position: absolute; + text-shadow: 1px 1px #102016; + top: 28px; + transform: translateX(-50%); + white-space: nowrap; +} + +@keyframes floating-heal { + 0% { + opacity: 0; + transform: translate(-50%, 6px) scale(0.85); + } + + 18% { + opacity: 1; + transform: translate(-50%, 0) scale(1); + } + + 100% { + opacity: 0; + transform: translate(-50%, -20px) scale(1); + } +} + +.buff { + background: #244c36; + color: #77dfa0; +} + +.debuff { + background: #5c2028; + color: #ff8290; +} + +.combat-log ol { + list-style: none; + margin: 17px 0 0; + padding: 0; +} + +.combat-log li { + border-bottom: 1px solid #30313a; + color: var(--muted); + font-size: 17px; + line-height: 1.1; + padding: 8px 2px; +} + +.combat-log .heal { + color: #72d99b; +} + +.combat-log .danger { + color: #f16d7b; +} + +.combat-log .loot { + color: var(--gold); +} + +.action-panel { + margin-top: 18px; + padding: 12px 18px; +} + +.resource-row strong { + color: var(--ink); + font-size: 20px; +} + +.active-target-card { + align-items: center; + background: #29291f; + border: 2px solid var(--gold); + box-shadow: inset 0 0 0 2px #5f4d28; + display: flex; + gap: 11px; + min-width: 300px; + padding: 9px 12px; +} + +.active-target-card > .role { + flex: 0 0 30px; + font-size: 9px; + height: 30px; + width: 30px; +} + +.active-target-card strong, +.active-target-card small { + display: block; +} + +.active-target-card strong { + color: #ffe29a; +} + +.active-target-card small { + color: var(--muted); + margin-top: 3px; +} + +.mana-wrap { + color: #82bfff; + font-size: 17px; + text-align: right; + width: min(360px, 50%); +} + +.party-mana-wrap { + text-align: left; + width: 100%; +} + +.party-mana-wrap > span { + display: block; + margin-bottom: 4px; +} + +.action-panel .resource-row { + justify-content: flex-start; +} + +.mana-bar { + margin-top: 4px; +} + +.mana-bar > span { + background: var(--blue); + box-shadow: inset 0 4px #60a9ed; +} + +.spell-bar { + display: grid; + gap: 11px; + grid-template-columns: repeat(5, minmax(0, 1fr)); + margin-top: 16px; +} + +.spell-bar.six-slots { + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.vertical-spell-bar, +.vertical-spell-bar.six-slots { + align-content: start; + flex: 1; + grid-template-columns: 1fr; + margin-top: 16px; + overflow-x: hidden; + overflow-y: auto; + padding-right: 4px; +} + +.vertical-spell-bar .spell { + min-height: 64px; + padding: 8px 8px 8px 56px; +} + +.vertical-spell-bar .spell-icon { + font-size: 12px; + height: 30px; + left: 10px; + margin: 0; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 30px; +} + +.vertical-spell-bar .spell strong { + font-size: 14px; +} + +.vertical-spell-bar .spell small { + font-size: 12px; +} + +.vertical-spell-bar .spell kbd { + left: auto; + right: 4px; +} + +.empty-spell { + align-items: center; + color: #5e5b68; + display: flex; + justify-content: center; +} + +.spell { + background: var(--panel-light); + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + min-height: 112px; + outline: 2px solid #494756; + padding: 8px 5px; + position: relative; +} + +.spell:hover:not(:disabled) { + outline-color: var(--gold); + transform: translateY(-2px); +} + +.spell:disabled { + cursor: not-allowed; + filter: grayscale(0.8); + opacity: 0.48; +} + +.spell kbd { + align-items: center; + background: #090a0d; + color: var(--muted); + display: inline-flex; + font-size: 11px; + justify-content: center; + left: 4px; + min-height: 24px; + max-width: calc(100% - 8px); + overflow: hidden; + padding: 2px 6px; + position: absolute; + text-overflow: ellipsis; + white-space: nowrap; + top: 4px; +} + +.spell-icon { + align-items: center; + background: #5f4124; + border: 2px solid #0a0b0e; + color: #ffe6a7; + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 17px; + height: 42px; + justify-content: center; + margin: 1px auto 7px; + width: 42px; +} + +.spell-group { + background: #694e24; +} + +.spell-shield { + background: #255b78; +} + +.spell-cleanse { + background: #573c75; +} + +.spell strong, +.spell small { + display: block; +} + +.spell strong { + font-size: 16px; +} + +.spell small { + color: var(--muted); +} + +.spell > i { + align-items: center; + background: rgba(7, 8, 11, 0.84); + display: flex; + font-style: normal; + inset: 0; + justify-content: center; + position: absolute; +} + +.result-log { + background: #17181e; + border: 2px solid #08090c; + margin-top: 14px; + max-height: 240px; + overflow-y: auto; + padding: 8px; + text-align: left; +} + +.log-entry { + border-bottom: 1px solid #30313a; + color: var(--muted); + font-size: 16px; + line-height: 1.2; + padding: 7px 2px; +} + +.log-entry:last-child { + border-bottom: 0; +} + +.log-entry.heal { + color: #72d99b; +} + +.log-entry.danger { + color: #f16d7b; +} + +.log-entry.loot { + color: var(--gold); +} + +.result-screen, +.pause-screen { + align-items: center; + background: rgba(5, 5, 8, 0.88); + display: flex; + inset: 0; + justify-content: center; + position: fixed; + z-index: 10; +} + +.result-screen > div, +.pause-screen > div { + background: var(--panel); + border: 3px solid #0b0c0f; + box-shadow: 8px 8px 0 #050507; + max-width: 520px; + outline: 2px solid var(--gold); + padding: 32px; + text-align: center; +} + +.result-screen p:not(.eyebrow), +.pause-screen p:not(.eyebrow) { + color: var(--muted); + font-size: 22px; + margin-top: 15px; +} + +.reward-summary { + margin-top: 14px; +} + +.reward-summary > p { + margin-top: 8px !important; +} + +.reward-summary .level-gain { + color: var(--gold) !important; + font-family: 'Press Start 2P', monospace; + font-size: 12px !important; +} + +.level-gain small { + color: #d8c79d; + display: block; + font-family: 'VT323', monospace; + font-size: 18px; + margin-top: 9px; +} + +.reward-summary .ability-unlock { + align-items: center; + background: #213c2b; + border: 1px solid #4f9c68; + color: #83dea2 !important; + display: flex; + font-size: 18px !important; + gap: 9px; + justify-content: center; + padding: 7px; +} + +.reward-summary .efficiency-result small { + color: var(--muted); + display: block; + margin-top: 3px; +} + +.ability-unlock span { + color: var(--gold); + font-family: 'Press Start 2P', monospace; +} + +.reward-summary .reward-error { + color: #ff8190 !important; +} + +.run-loot-rolls { + display: grid; + gap: 6px; + margin-top: 12px; +} + +.run-loot-rolls > div { + align-items: center; + background: #17181e; + border-left: 3px solid #55515e; + column-gap: 10px; + display: grid; + font-size: 15px; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); + padding: 6px 9px; + text-align: left; +} + +.run-loot-rolls > div.dropped { + border-left-color: var(--gold); +} + +.run-loot-rolls strong { + color: var(--muted); +} + +.run-loot-rolls span { + color: var(--ink); + text-align: right; +} + +.run-loot-rolls > small { + color: var(--muted); +} + +.upgrade-choice-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 16px; +} + +.upgrade-choice-grid button { + background: #20232c; + border: 2px solid #08090c; + color: var(--ink); + cursor: pointer; + margin-top: 0; + min-height: 128px; + min-width: 0; + outline: 2px solid #4d4c58; + padding: 12px; + text-align: left; +} + +.upgrade-choice-grid button:hover, +.upgrade-choice-grid button:focus-visible { + outline-color: var(--gold); +} + +.upgrade-choice-grid strong, +.upgrade-choice-grid small { + display: block; +} + +.upgrade-choice-grid strong { + color: var(--gold); + font-size: 10px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.upgrade-choice-grid .selected-upgrade, +.selected-upgrade { + outline-color: var(--gold); +} + +.upgrade-choice-grid small, +.roguelike-upgrade-list { + color: var(--muted); + font-size: 15px; + line-height: 1.25; + margin-top: 10px; + overflow-wrap: anywhere; +} + +.bonus-item { + border-top: 2px solid var(--gold); + margin-top: 14px; + padding-top: 14px; +} + +.bonus-item-detail { + align-items: center; + display: flex; + gap: 10px; + margin-top: 8px; +} + +.bonus-item-detail > span { + align-items: center; + background: #4a3720; + color: var(--gold); + display: flex; + flex: 0 0 38px; + font-family: 'Press Start 2P', monospace; + height: 38px; + justify-content: center; +} + +.bonus-item-detail strong { + color: var(--rarity-color, var(--ink)); + font-size: 16px; +} + +.bonus-item-detail small { + color: var(--muted); +} + +.pvp-match-screen { + gap: 16px; +} + +.pvp-board { + display: grid; + gap: 16px; + grid-template-columns: minmax(0, 1fr) minmax(280px, 0.8fr) minmax(0, 1fr); +} + +.pvp-side, +.pvp-middle-panel { + gap: 12px; +} + +.pvp-vertical-spell-bar, +.pvp-vertical-spell-bar.six-slots { + grid-template-columns: 1fr; +} + +.pvp-vertical-spell-bar .spell { + min-height: 86px; +} + +.pvp-screen-tools { + align-items: center; + display: flex; + gap: 12px; +} + +.pvp-resource-row { + justify-content: flex-end; +} + +.pvp-resource-wrap { + color: #82bfff; + min-width: 220px; + text-align: right; + width: min(240px, 100%); +} + +.pvp-resource-wrap > span { + display: block; + margin-bottom: 4px; +} + +.pvp-side .party-member, +.pvp-side .party-member > div, +.pvp-side .party-member > small { + min-width: 0; +} + +.pvp-enemy-race { + display: grid; + gap: 12px; +} + +.pvp-choice-columns { + display: grid; + gap: 16px; + grid-template-columns: 1fr; + margin-top: 16px; +} + +.pvp-choice-columns > div > strong { + display: block; + margin-bottom: 8px; +} + +.pvp-choice-columns .upgrade-choice-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.pvp-choice-columns .upgrade-choice-grid button { + background: #252833; + min-height: 120px; + padding: 14px; +} + +.pvp-leaderboard-row { + grid-template-columns: 88px minmax(0, 1fr) 90px 90px 90px; +} + +.pvp-upgrade-dialog { + max-width: 1120px !important; + text-align: left !important; + width: min(1120px, calc(100vw - 32px)); +} + +.pvp-upgrade-dialog .upgrade-choice-grid strong { + color: #ffe8a5; + font-size: 11px; + line-height: 1.6; +} + +.pvp-upgrade-dialog .upgrade-choice-grid small { + color: #d3d9e6; + font-size: 16px; + line-height: 1.35; +} + +.pvp-upgrade-dialog .upgrade-choice-grid button.selected-upgrade { + background: #303427; + box-shadow: inset 0 0 0 2px #6e5727; +} + +.result-screen button, +.pause-screen button { + background: var(--gold); + border: 2px solid #08090c; + color: #19150e; + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 9px; + margin-top: 22px; + outline: 2px solid #816630; + padding: 13px 18px; +} + +.result-screen .secondary-result-button, +.pause-screen .secondary-result-button { + background: #252630; + color: var(--ink); + margin-left: 10px; + outline-color: #4d4c58; +} + +.pause-screen button { + display: block; + width: 100%; +} + +.pause-screen .secondary-result-button { + margin-left: 0; + margin-top: 12px; +} + +.pause-screen button:focus-visible { + outline: 3px solid #fff4a8 !important; + box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65); + z-index: 1; +} + +@media (max-height: 720px) { + .game-shell { + margin: 6px auto; + width: min(1180px, calc(100% - 20px)); + } + + .topbar { + padding: 9px 14px; + } + + .app-header { + min-height: 56px; + } + + .character-summary { + gap: 9px; + } + + .character-summary strong { + font-size: 17px; + } + + .menu-screen, + .content-screen, + .message-panel { + margin-top: 8px; + padding: 14px; + } + + .main-menu-grid { + gap: 8px; + } + + .menu-card { + min-height: 72px; + padding: 10px; + } + + .menu-card > span, + .class-portrait { + flex-basis: 44px; + font-size: 14px; + height: 44px; + } + + .menu-card strong { + margin-bottom: 4px; + } + + .menu-card small { + font-size: 15px; + } + + .screen-heading { + padding-bottom: 10px; + } + + .screen-heading .eyebrow { + display: none; + } + + .screen-heading h1 { + font-size: 16px; + } + + .dungeon-card { + gap: 12px; + grid-template-columns: 72px 1fr; + margin-top: 10px; + padding: 9px 10px; + } + + .activity-select, + .part-buttons { + grid-column: 1 / -1; + } + + .dungeon-art { + font-size: 15px; + height: 62px; + } + + .dungeon-card p:not(.eyebrow) { + font-size: 16px; + line-height: 1; + } + + .tag-row { + gap: 5px; + margin-top: 6px; + } + + .tag-row span { + font-size: 14px; + padding: 2px 6px; + } + + .part-buttons { + flex-direction: column; + gap: 5px; + } + + .part-buttons .primary-button, + .text-button, + .back-button { + padding: 7px 10px; + } + + .difficulty-section, + .loot-preview-section, + .leaderboard-section { + margin-top: 8px; + padding: 8px; + } + + .equipment-heading .eyebrow { + display: none; + } + + .equipment-heading h2 { + font-size: 11px; + } + + .difficulty-select-row select { + min-height: 36px; + } + + .difficulty-summary { + padding: 8px 10px; + } + + .difficulty-summary small { + font-size: 14px; + } + + .difficulty-summary dl > div { + padding: 5px 6px; + } + + .customize-tabs { + margin-top: 10px; + } + + .customize-layout, + .embedded-screen .gear-summary, + .embedded-screen .talent-toolbar { + margin-top: 10px; + } + + .customize-screen { + display: flex; + flex-direction: column; + height: calc(100dvh - 76px); + overflow: hidden; + } + + .customize-screen > .customize-layout, + .customize-screen > .embedded-screen { + flex: 1; + min-height: 0; + overflow-y: auto; + } + + .customize-layout { + grid-template-columns: 210px minmax(0, 1fr); + } + + .class-picker, + .loadout-editor { + min-height: 0; + } + + .ability-slots button { + min-height: 76px; + } + + .ability-library { + max-height: 190px; + } +} + +@media (max-width: 800px) { + .auth-panel { + grid-template-columns: 1fr; + } + + .auth-brand { + min-height: 260px; + } + + .combat-layout { + grid-template-columns: 1fr; + } + + .party-grid, + .spell-bar, + .spell-bar.six-slots, + .main-menu-grid, + .roguelike-mode-grid, + .upgrade-choice-grid, + .ability-library { + grid-template-columns: 1fr; + } + + .party-member:first-child, + .menu-card:first-child { + grid-column: auto; + } + + .spell { + min-height: 92px; + } + + .resource-row { + align-items: stretch; + flex-direction: column; + gap: 12px; + } + + .roguelike-variant-row, + .roguelike-option-panel, + .roguelike-timing-row, + .pvp-screen-tools { + align-items: stretch; + flex-direction: column; + } + + .pvp-board, + .pvp-choice-columns, + .pvp-choice-columns .upgrade-choice-grid { + grid-template-columns: 1fr; + } + + .active-target-card, + .mana-wrap { + width: 100%; + } + + .active-target-card { + min-width: 0; + } + + .customize-layout { + grid-template-columns: 1fr; + } + + .talent-tier { + grid-template-columns: 1fr; + } + + .tier-label { + border-bottom: 2px solid #353640; + border-right: 0; + padding-bottom: 9px; + } + + .tier-talents { + grid-template-columns: 1fr; + } + + .talent-node { + grid-column: auto !important; + } + + .gear-summary, + .equipment-layout, + .crafting-layout { + grid-template-columns: 1fr; + } + + .item-comparison { + grid-template-columns: 1fr; + min-height: 0; + } + + .set-bonus-list > div { + grid-template-columns: 1fr; + } + + .character-summary { + flex-wrap: wrap; + justify-content: flex-end; + } + + .difficulty-select-row, + .toggle-heading { + align-items: stretch; + flex-direction: column; + } + + .difficulty-select-row label { + align-items: stretch; + flex-direction: column; + } + + .difficulty-summary { + grid-template-columns: 1fr; + } + + .difficulty-summary dl { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .difficulty-grid { + grid-template-columns: 1fr; + } + + .leaderboard-table { + overflow-x: auto; + } + + .leaderboard-header, + .leaderboard-row { + min-width: 760px; + } + + .difficulty-grid > button { + min-height: auto; + } + + .loot-preview-grid { + grid-template-columns: 1fr; + } + + .gear-stat { + border-left: 0; + border-top: 1px solid #464650; + padding-left: 0; + padding-top: 8px; + } + + .comparison-arrow { + padding: 2px 0; + } + + .class-picker { + border-bottom: 2px solid #353640; + border-right: 0; + padding-bottom: 10px; + padding-right: 0; + } + + .ability-slots { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dungeon-card { + grid-template-columns: 1fr; + } + + .progress-summary { + grid-template-columns: 1fr; + } + + .settings-heading, + .settings-footer { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + + .dual-screen-settings { + grid-template-columns: 1fr; + } + + .dual-screen-settings > small { + grid-column: auto; + } + + .android-display-list { + grid-column: auto; + } + + .dual-screen-actions { + min-width: 0; + } + + .controller-icon-options { + grid-template-columns: 1fr; + } + + .binding-list { + grid-template-columns: 1fr; + } + + .controller-keyboard { + max-height: calc(100vh - 24px); + overflow-y: auto; + } + + .controller-keyboard-grid { + grid-template-columns: repeat(6, 1fr); + } + + .controller-keyboard-actions { + grid-template-columns: 1fr 1fr; + } + + .target-controls { + align-items: flex-end; + flex-direction: column; + gap: 7px; + } + + .dual-bottom-status { + grid-template-columns: 1fr; + } + + .dual-bottom-actions { + min-height: 0; + padding: 18px; + } + + .dual-bottom-actions .active-target-card, + .dual-bottom-actions .mana-wrap { + min-width: 0; + width: 100%; + } + + .dual-bottom-actions .spell { + min-height: 125px; + } +} + +/* ─── Admin Panel ─── */ +.admin-tabs { + display: flex; + gap: 8px; + margin: 16px 0; +} + +.admin-tab { + background: var(--panel-light); + border: 2px solid #090a0d; + color: var(--muted); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + outline: 2px solid #494754; + padding: 10px 18px; + text-transform: uppercase; +} + +.admin-tab.active { + background: var(--gold); + color: #19150e; + outline-color: #816630; +} + +.admin-tab:hover:not(.active) { + color: var(--ink); + outline-color: var(--gold); +} + +.admin-panel { + display: flex; + flex-direction: column; + gap: 12px; +} + +.admin-search { + background: #111217; + border: 2px solid #090a0d; + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 8px; + outline: 2px solid #3e3d47; + padding: 10px 12px; + width: 100%; +} + +.admin-search:focus { + outline-color: var(--gold); +} + +.admin-group-header { + color: var(--gold); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 10px; + margin: 12px 0 8px; + padding: 8px 4px; + text-transform: uppercase; + user-select: none; +} + +.admin-group-header:hover { + color: #f0cb79; +} + +.admin-grid { + display: grid; + gap: 8px; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); +} + +.admin-card { + background: var(--panel-light); + border: 2px solid #090a0d; + display: flex; + flex-direction: column; + gap: 8px; + outline: 2px solid #494754; + padding: 12px; +} + +.boss-image-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.boss-image-card { + align-items: start; +} + +.boss-image-card img { + aspect-ratio: 1; + background: #481e29; + border: 2px solid #090a0d; + display: block; + object-fit: cover; + outline: 2px solid #7a2940; + width: 84px; +} + +.boss-upload-button { + background: #15161c; + border: 2px solid #090a0d; + color: var(--gold); + cursor: pointer; + display: inline-flex; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + outline: 2px solid #41404a; + padding: 8px 10px; + text-transform: uppercase; +} + +.boss-upload-button input { + display: none; +} + +.boss-upload-button:has(input:disabled) { + color: var(--muted); + cursor: wait; +} + +.admin-item-header { + align-items: center; + display: flex; + gap: 10px; +} + +.admin-glyph { + font-family: 'Press Start 2P', monospace; + font-size: 16px; + height: 32px; + line-height: 32px; + text-align: center; + width: 32px; +} + +.admin-item-meta { + color: var(--muted); + display: block; + font-size: 7px; + margin-top: 2px; +} + +.admin-item-desc { + color: var(--muted); + font-size: 11px; + line-height: 1.4; + margin: 0; +} + +.admin-item-stats { + display: flex; + gap: 16px; +} + +.admin-item-stats > span { + color: var(--green); + font-family: 'Press Start 2P', monospace; + font-size: 7px; +} + +.admin-edit-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.admin-edit-form label { + align-items: center; + color: var(--muted); + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + gap: 8px; + text-transform: uppercase; +} + +.admin-edit-form input, +.admin-edit-form select, +.admin-edit-form textarea { + background: #111217; + border: 2px solid #090a0d; + color: var(--ink); + flex: 1; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + outline: 2px solid #3e3d47; + padding: 6px 8px; +} + +.admin-edit-form textarea { + min-height: 48px; + resize: vertical; +} + +.admin-edit-form input:focus, +.admin-edit-form select:focus, +.admin-edit-form textarea:focus { + outline-color: var(--gold); +} + +.admin-edit-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +/* Loot tab */ +.admin-loot-selectors { + display: flex; + gap: 16px; +} + +.admin-loot-selectors label { + align-items: center; + color: var(--muted); + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + gap: 8px; + text-transform: uppercase; +} + +.admin-loot-selectors select { + background: #15161c; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + min-height: 36px; + outline: 2px solid #41404a; + padding: 0 10px; +} + +.admin-loot-title { + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 9px; + margin: 8px 0; +} + +.admin-empty { + color: var(--muted); + font-size: 16px; +} + +.admin-loot-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.admin-loot-row { + align-items: center; + background: var(--panel-light); + border: 2px solid #090a0d; + display: flex; + gap: 10px; + outline: 2px solid #494754; + padding: 8px 12px; +} + +.admin-loot-name { + flex: 1; + font-size: 14px; +} + +.admin-loot-weight { + color: var(--muted); + font-family: 'Press Start 2P', monospace; + font-size: 7px; +} + +.admin-loot-chance { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 7px; +} + +.admin-qty-input { + background: #111217; + border: 2px solid #090a0d; + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 7px; + outline: 2px solid #3e3d47; + padding: 4px 6px; + width: 50px; +} + +.admin-qty-input:focus { + outline-color: var(--gold); +} + +.danger-button { + background: var(--red); + border: 2px solid #08090c; + color: #fff; + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + outline: 2px solid #7a2940; + padding: 8px 12px; +} + +.danger-button:hover { + background: var(--red-bright); +} + +.admin-add-section { + margin-top: 12px; +} + +.admin-add-section summary { + color: var(--gold); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + margin-bottom: 8px; + text-transform: uppercase; +} + +.admin-add-section summary:hover { + color: #f0cb79; +} + +.admin-add-form { + align-items: end; + display: flex; + gap: 12px; +} + +.admin-add-form label { + color: var(--muted); + display: flex; + flex-direction: column; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + gap: 4px; + text-transform: uppercase; +} + +.admin-add-form select, +.admin-add-form input { + background: #15161c; + border: 2px solid #090a0d; + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 8px; + outline: 2px solid #41404a; + padding: 8px 10px; +} + +.admin-add-form select:focus, +.admin-add-form input:focus { + outline-color: var(--gold); +} + +.admin-crafting-filters { + align-items: end; + display: grid; + gap: 12px; + grid-template-columns: minmax(120px, 0.5fr) minmax(180px, 0.8fr) minmax(260px, 1.5fr); +} + +.admin-crafting-filters label, +.admin-recipe-selector { + align-items: stretch; + color: var(--muted); + display: flex; + flex-direction: column; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + gap: 8px; + text-transform: uppercase; +} + +.admin-crafting-filters select, +.admin-recipe-selector select { + background: #15161c; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + flex: 1; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + min-height: 36px; + outline: 2px solid #41404a; + padding: 0 10px; +} + +.admin-recipe-header { + align-items: center; + display: flex; + gap: 10px; + margin: 8px 0; + padding: 12px; + background: var(--panel-light); + border: 2px solid #090a0d; + outline: 2px solid #494754; +} + +.admin-recipe-image { + aspect-ratio: 1; + background: #1c1e25; + border: 2px solid #090a0d; + display: block; + object-fit: cover; + outline: 2px solid #41404a; + width: 64px; +} + +.admin-inline-field { + color: var(--muted); + display: flex; + flex-direction: column; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + gap: 6px; + text-transform: uppercase; +} + +.admin-inline-field input { + background: #111217; + border: 2px solid #090a0d; + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 8px; + min-width: 230px; + outline: 2px solid #3e3d47; + padding: 8px 10px; +} + +.admin-recipe-actions { + display: flex; + gap: 10px; + margin-left: auto; +} + +@media (max-width: 800px) { + .admin-crafting-filters { + grid-template-columns: 1fr; + } + + .admin-recipe-header, + .admin-recipe-actions { + align-items: stretch; + flex-direction: column; + } + + .admin-recipe-actions { + margin-left: 0; + } +} + +.admin-rename-input { + background: #111217; + border: 2px solid #090a0d; + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 8px; + min-width: 180px; + outline: 2px solid #3e3d47; + padding: 6px 8px; + width: 100%; +} + +.admin-rename-input:focus { + outline-color: var(--gold); +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..b9ea69d --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,795 @@ +import { useEffect, useState } from 'react' +import './App.css' +import { CombatScreen } from './components/CombatScreen' +import { AuthScreen } from './components/AuthScreen' +import { CustomizeScreen } from './components/CustomizeScreen' +import { EquipmentScreen } from './components/EquipmentScreen' +import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen' +import { TalentScreen } from './components/TalentScreen' +import { SettingsScreen } from './components/SettingsScreen' +import { + loadCpuPvpLeaderboard, + type CpuPvpLeaderboardEntry, + type PvpContentType, +} from './pvpRoguelike' +import { + loadAuthSession, + logoutAccount, + type Account, + type AuthSession, + type CharacterProfile, +} from './profile' +import { getGameMode, type GameMode } from './gameRepository' +import { focusFirstControl } from './input.tsx' + +type Screen = + | 'menu' + | 'dungeons' + | 'combat' + | 'raids' + | 'roguelike' + | 'pvp' + | 'customize' + | 'equipment' + | 'talents' + | 'settings' + +const MENU_ITEMS: Array<{ + screen: Screen + label: string + glyph: string + description: string +}> = [ + { screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a five-player party through dangerous encounters.' }, + { screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide a ten-player party through three-phase challenges.' }, + { screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' }, + { screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' }, + { screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' }, + { screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' }, +] + +const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty' + +function activityInitials(name: string) { + return name + .split(/\s+/) + .filter((word) => /^[A-Za-z0-9]/.test(word)) + .slice(0, 2) + .map((word) => word[0].toUpperCase()) + .join('') +} + +type RoguelikeUpgradeTiming = 'boss' | 'encounter' +type RoguelikeVariant = 'pve' | 'pvp' +type RoguelikeAbilityLabelMode = 'ability' | 'slot' + +function App() { + const [screen, setScreen] = useState('menu') + const [account, setAccount] = useState(null) + const [profile, setProfile] = useState(null) + const [authChecked, setAuthChecked] = useState(false) + const [gameMode, setGameMode] = useState(getGameMode()) + const [serverMessage, setServerMessage] = useState('') + const [selectedDifficultyId, setSelectedDifficultyId] = useState(() => { + const saved = Number(window.localStorage.getItem(LAST_DIFFICULTY_KEY)) + return Number.isFinite(saved) && saved > 0 ? saved : 1 + }) + const [selectedDungeonId, setSelectedDungeonId] = useState(1) + const [selectedRaidId, setSelectedRaidId] = useState(2) + const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon') + const [roguelikeVariant, setRoguelikeVariant] = useState('pve') + const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState('encounter') + const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState('ability') + const [pvpContentType, setPvpContentType] = useState('dungeon') + const [selectedPart, setSelectedPart] = useState(1) + const [combatContentId, setCombatContentId] = useState(1) + const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1') + const [showLoot, setShowLoot] = useState(false) + const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence') + const [showLeaderboard, setShowLeaderboard] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + loadAuthSession() + .then((session) => { + setAccount(session.account) + setProfile(session.profile) + }) + .catch((reason: unknown) => { + setServerMessage( + reason instanceof Error + ? `${reason.message} Offline play is still available.` + : 'Unable to reach the server. Offline play is still available.', + ) + }) + .finally(() => setAuthChecked(true)) + }, []) + + useEffect(() => { + if (screen === 'combat') return + window.requestAnimationFrame(() => { + focusFirstControl() + }) + }, [screen]) + + useEffect(() => { + window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId)) + }, [selectedDifficultyId]) + + const [cpuLeaderboard, setCpuLeaderboard] = useState([]) + + useEffect(() => { + setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) + }, [pvpContentType, screen, roguelikeVariant]) + + function acceptSession(session: AuthSession) { + setAccount(session.account) + setProfile(session.profile) + setGameMode(getGameMode()) + setScreen('menu') + setError('') + setServerMessage('') + } + + async function signOut() { + try { + await logoutAccount() + setAccount(null) + setProfile(null) + setGameMode(getGameMode()) + setScreen('menu') + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Unable to sign out.') + } + } + + if (error) { + return ( +
+
+

Database Error

+

Character Unavailable

+

{error}

+
+
+ ) + } + + if (!authChecked) { + return ( +
+
+

Opening Chronicle

+

Loading...

+
+
+ ) + } + + if (!account || !profile) { + return ( + + ) + } + + if (screen === 'combat') { + const dungeon = combatContentId < 0 + ? profile.dungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profile.dungeons[0] + : profile.dungeons.find((candidate) => candidate.id === combatContentId) ?? profile.dungeons[0] + const difficulty = dungeon.difficulties.find( + (candidate) => candidate.id === selectedDifficultyId, + ) ?? dungeon.difficulties[0] + const roguelikePool = profile.dungeons + .filter((candidate) => candidate.contentType === roguelikeKind) + .flatMap((candidate) => candidate.encounters) + const startPart = selectedPart + return ( + { + setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons') + }} + onProfileUpdated={setProfile} + /> + ) + } + + if (screen === 'pvp') { + const pvpPool = profile.dungeons + .filter((candidate) => candidate.contentType === pvpContentType) + .flatMap((candidate) => candidate.encounters) + return ( + { + setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) + setRoguelikeVariant('pvp') + setScreen('roguelike') + }} + onProfileUpdated={setProfile} + profile={profile} + /> + ) + } + + const levelStart = profile.character.currentLevelExperience + const levelEnd = profile.character.nextLevelExperience + const experienceIntoLevel = profile.character.experience - levelStart + const experienceForLevel = Math.max(1, levelEnd - levelStart) + const experiencePercent = profile.character.level >= profile.maxLevel + ? 100 + : Math.min(100, (experienceIntoLevel / experienceForLevel) * 100) + const dungeonOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'dungeon') + const raidOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'raid') + const dungeon = dungeonOptions.find((candidate) => candidate.id === selectedDungeonId) + ?? dungeonOptions[0]! + const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId) + ?? raidOptions[0] + const activity = screen === 'raids' && raid ? raid : dungeon + const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions + const selectedDifficulty = activity.difficulties.find( + (candidate) => candidate.id === selectedDifficultyId, + ) ?? activity.difficulties[0] + const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel + const completedSections = activity.contentType === 'raid' + ? profile.completedRaidPhases + : profile.completedDungeonParts + const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part' + const parts = [ + { part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true }, + { part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 }, + { part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 }, + ] + const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5 + const lootPreviewEncounters = [...activity.encounters] + .filter((encounter) => encounter.isBoss) + .sort((a, b) => lootSort === 'boss' + ? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence + : a.sequence - b.sequence) + + return ( +
+
+ +
+ {profile.character.name} + Level {profile.character.level} + Item Level {profile.gearStats.averageItemLevel.toFixed(1)} +
+ +
+ +
+
+ + {screen === 'menu' && ( +
+
+ {MENU_ITEMS.map((item) => ( + + ))} +
+
+ )} + + {screen === 'roguelike' && ( +
+ setScreen('menu')} + /> +
+ + +
+ {roguelikeVariant === 'pve' && ( + <> +
+
+

Upgrade Timing

+

Buff Drafts

+
+
+ + +
+
+
+
+

Upgrade Labels

+

Display Mode

+
+
+ + +
+
+
+ + +
+ + )} + {roguelikeVariant === 'pvp' && ( + <> +
+
+

Match Type

+

PvP Roguelike

+
+
+ + +
+
+
+ {gameMode === 'offline' ? 'C' : 'Q'} +
+ {gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'} + + {gameMode === 'offline' + ? 'Offline mode always places you against a random CPU 1-5.' + : 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'} + +
+ +
+
+
+
+

CPU Leaderboard

+

{pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}

+
+
+
+
+ Rank + Player + CPU + Clears + Result +
+ {cpuLeaderboard.map((entry, index) => ( +
+ #{index + 1} + {entry.characterName} + CPU {entry.cpuDifficulty} + {entry.encountersCleared} + {entry.result} +
+ ))} + {cpuLeaderboard.length === 0 && ( +
+ No CPU runs recorded yet for this mode. +
+ )} +
+
+ + )} +
+ )} + + {(screen === 'dungeons' || screen === 'raids') && ( +
+ setScreen('menu')} + /> +
+
+ {activityInitials(activity.name)} +
+
+

{activity.locationName}

+

{activity.name}

+

{activity.description}

+
+ Level {activity.recommendedLevel} + {activity.partySize} Players + {selectedDifficulty.name} + Component Level {selectedDifficulty.droppedItemLevel} + {Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP +
+
+ {activityOptions.length > 1 && ( + + )} +
+ {parts.map((p) => ( + + ))} +
+
+
+
+
+

Challenge Tier

+

Difficulty

+
+ +
+
+
+ {selectedDifficulty.name} + {difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : selectedDifficulty.description} +
+
+
Health
{selectedDifficulty.healthMultiplier.toFixed(2)}x
+
Damage
{selectedDifficulty.damageMultiplier.toFixed(2)}x
+
XP
{selectedDifficulty.experienceMultiplier.toFixed(1)}x
+
Components
iLvl {selectedDifficulty.droppedItemLevel}
+
+
+
+
+
+
+

Encounter Rewards

+

{selectedDifficulty.name} Loot Tables

+
+ +
+ {showLoot && ( + <> +
+ +
+

+ Bosses roll {lootChanceSlots} loot chances against the listed table, 1-3 materials each + {activity.completionItemLevel + ? ` - full clear guarantees iLvl ${activity.completionItemLevel}` + : ''} +

+
+ {lootPreviewEncounters.map((encounter) => { + const loot = encounter.lootTables.filter( + (entry) => entry.difficultyId === selectedDifficulty.id, + ) + return ( +
+
+ {encounter.isBoss ? ( + {`${encounter.enemyName} + ) : ( + {encounter.sequence} + )} +
+ {encounter.enemyName} + {loot.length > 0 ? `${lootChanceSlots} weighted chances, 1-3 each` : 'No component table'} +
+
+
+ {loot.map((item) => ( +
+ {item.glyph} +
+ {item.name} + {item.slot} - iLvl {item.itemLevel} +
+ {item.dropWeight}% weight +
+ ))} +
+
+ ) + })} +
+ + )} +
+
+
+
+

Efficiency Rankings

+

{selectedDifficulty.name} Leaderboard

+
+ +
+ {showLeaderboard && ( + <> +

+ {gameMode === 'offline' + ? 'Offline runs are not submitted' + : 'Lowest resource spent ranks first'} +

+
+ {([ + { key: 'part_1', label: `${sectionName} 1` }, + { key: 'part_2', label: `${sectionName} 2` }, + { key: 'part_3', label: `${sectionName} 3` }, + { key: 'full_run', label: 'Full Run' }, + ] as const).map((tab) => ( + + ))} +
+
+
+ Rank + Healer + Class + Level + Item Level + Resource + Time +
+ {activity.leaderboards[leaderboardCategory] + .filter((entry) => entry.difficultyId === selectedDifficulty.id) + .map((entry) => ( +
+ #{entry.rank} + {entry.characterName} + {entry.className} + {entry.characterLevel} + {entry.averageItemLevel.toFixed(1)} + {entry.resourceSpent} + {entry.durationSeconds}s +
+ ))} + {activity.leaderboards[leaderboardCategory].filter( + (entry) => entry.difficultyId === selectedDifficulty.id, + ).length === 0 && ( +
+ {gameMode === 'offline' + ? 'Connect with an online character to compete in rankings.' + : 'Complete this difficulty to claim the first ranking.'} +
+ )} +
+ + )} +
+
+ )} + + {screen === 'customize' && ( + setScreen('menu')} + onSaved={setProfile} + /> + )} + + {screen === 'talents' && ( + setScreen('menu')} + onUpdated={setProfile} + /> + )} + + {screen === 'equipment' && ( + setScreen('menu')} + onUpdated={setProfile} + /> + )} + + {screen === 'settings' && ( + setScreen('menu')} /> + )} + +
+ ) +} + +function ScreenHeading({ + eyebrow, + title, + onBack, +}: { + eyebrow: string + title: string + onBack: () => void +}) { + return ( +
+
+

{eyebrow}

+

{title}

+
+ +
+ ) +} + +export default App diff --git a/src/admin.tsx b/src/admin.tsx new file mode 100644 index 0000000..d314694 --- /dev/null +++ b/src/admin.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import './App.css' +import { AdminScreen } from './components/AdminScreen' + +createRoot(document.getElementById('root')!).render( + + window.close()} /> + , +) diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/components/AdminScreen.tsx b/src/components/AdminScreen.tsx new file mode 100644 index 0000000..da30de1 --- /dev/null +++ b/src/components/AdminScreen.tsx @@ -0,0 +1,766 @@ +import { useEffect, useState } from 'react' + +type AdminItem = { + id: number + slug: string + name: string + slot: string + rarity: string + itemLevel: number + healingPower: number + maxResourceBonus: number + glyph: string + imageUrl: string + description: string +} + +type AdminEncounter = { + id: number + dungeonId: number + sequence: number + slug: string + enemyName: string + encounterType: string + imageUrl: string +} + +type AdminDifficulty = { + id: number + slug: string + name: string + droppedItemLevel: number +} + +type AdminLootEntry = { + encounterId: number + itemId: number + difficultyId: number + dropWeight: number + dropChance: number +} + +type AdminRecipeComponent = { + itemId: number + quantity: number +} + +type AdminRecipe = { + id: number + itemId: number + difficultyId: number | null + sourceDungeonId: number | null + sourceEncounterId: number | null + components: AdminRecipeComponent[] +} + +type AdminDungeon = { + id: number + slug: string + name: string +} + +type AdminData = { + items: AdminItem[] + encounters: AdminEncounter[] + difficulties: AdminDifficulty[] + encounterLoot: AdminLootEntry[] + craftingRecipes: AdminRecipe[] + dungeons: AdminDungeon[] +} + +const API = '/api/admin' + +async function fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init) + const body = await res.json() + if (!res.ok) throw new Error(body.error ?? 'Request failed') + return body +} + +export function AdminScreen({ onBack }: { onBack: () => void }) { + const [data, setData] = useState(null) + const [tab, setTab] = useState<'items' | 'bosses' | 'loot' | 'crafting'>('items') + const [error, setError] = useState('') + const [saving, setSaving] = useState>({}) + + useEffect(() => { + fetchJson(`${API}/data`) + .then(setData) + .catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load')) + }, []) + + if (error) return

{error}

+ if (!data) return

Loading admin data...

+ + return ( +
+
+

Developer Tools

Admin Panel

+ +
+ + {tab === 'items' && } + {tab === 'bosses' && } + {tab === 'loot' && } + {tab === 'crafting' && } +
+ ) +} + +function ItemsTab({ data, setData, setSaving, saving }: { + data: AdminData | null + setData: React.Dispatch> + setSaving: (s: Record | ((prev: Record) => Record)) => void + saving: Record +}) { + if (!data) return null + const [filter, setFilter] = useState('') + const [editId, setEditId] = useState(null) + const [form, setForm] = useState>({}) + + const groups = groupBy(data.items.filter((i) => + i.name.toLowerCase().includes(filter.toLowerCase()) || i.slug.includes(filter)), + (i) => i.slot, + ) + + async function saveItem(id: number) { + setSaving((prev) => ({ ...prev, [`item-${id}`]: true })) + try { + const body: Record = {} + if (form.name !== undefined) body.name = form.name + if (form.glyph !== undefined) body.glyph = form.glyph + if (form.description !== undefined) body.description = form.description + if (form.rarity !== undefined) body.rarity = form.rarity + if (form.slot !== undefined) body.slot = form.slot + if (form.itemLevel !== undefined) body.item_level = form.itemLevel + if (form.healingPower !== undefined) body.healing_power = form.healingPower + if (form.maxResourceBonus !== undefined) body.max_resource_bonus = form.maxResourceBonus + await fetchJson(`${API}/items/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + setData((prev) => prev ? { + ...prev, + items: prev.items.map((i) => i.id === id ? { ...i, ...form } : i), + } : prev) + setEditId(null) + setForm({}) + } catch (e: unknown) { + alert(e instanceof Error ? e.message : 'Save failed') + } finally { + setSaving((prev) => ({ ...prev, [`item-${id}`]: false })) + } + } + + return ( +
+ setFilter(e.target.value)} /> + {Object.entries(groups).map(([slot, items]) => ( +
+ {slot} ({items.length}) +
+ {items.map((item) => ( +
+ {editId === item.id ? ( +
+ + + + + + + +