Initial I Want to Heal app

This commit is contained in:
Warren H
2026-06-17 20:04:36 -04:00
parent 3880db1b58
commit 3c90998a61
109 changed files with 32775 additions and 0 deletions
+28
View File
@@ -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?
+87
View File
@@ -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-<timestamp>.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.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Game Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/admin.tsx"></script>
</body>
</html>
+101
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+54
View File
@@ -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")
}
+19
View File
@@ -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()
}
+21
View File
@@ -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
@@ -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 <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@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());
}
}
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".GameActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBar"
android:launchMode="standard"
android:resizeableActivity="true"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -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;
}
}
}
@@ -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);
}
});
}
}
@@ -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);
}
}
@@ -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);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">I want to Heal</string>
<string name="title_activity_main">I want to Heal</string>
<string name="package_name">com.warren.iwanttoheal</string>
<string name="custom_url_scheme">com.warren.iwanttoheal</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -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 <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
+29
View File
@@ -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
}
+3
View File
@@ -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')
+22
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+251
View File
@@ -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" "$@"
+94
View File
@@ -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
+5
View File
@@ -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'
+16
View File
@@ -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'
}
+9
View File
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+290
View File
@@ -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
);
+1013
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>testgame</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3730
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -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"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-labelledby="title desc">
<title id="title">Boss placeholder</title>
<desc id="desc">A placeholder boss icon with a monster silhouette.</desc>
<rect width="160" height="160" fill="#481e29"/>
<path fill="#1a0b10" d="M28 118h104v14H28z"/>
<path fill="#ef6b7a" d="M44 74c0-26 15-42 36-42s36 16 36 42v12c0 22-15 38-36 38S44 108 44 86z"/>
<path fill="#f0cb79" d="M40 55 18 31l32 8zm80 0 22-24-32 8z"/>
<path fill="#1a0b10" d="M58 77h17v13H58zm27 0h17v13H85zm-25 29h40v9H60z"/>
<path fill="#ffffff" opacity=".35" d="M55 47c8-10 20-14 33-11-18 3-29 12-35 27-3-5-3-10 2-16z"/>
</svg>

After

Width:  |  Height:  |  Size: 669 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-labelledby="title desc">
<title id="title">Equipment placeholder</title>
<desc id="desc">A placeholder equipment icon with an item silhouette.</desc>
<rect width="160" height="160" fill="#1c1e25"/>
<path fill="#090a0d" d="M30 128h100v12H30z"/>
<path fill="#e5b95f" d="M80 20 116 50v50l-36 28-36-28V50z"/>
<path fill="#15161c" d="M58 58h44v12H58zm0 22h44v12H58zm12 22h20v10H70z"/>
<path fill="#fff" opacity=".28" d="M55 50 80 30l25 20-25 13z"/>
</svg>

After

Width:  |  Height:  |  Size: 543 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+20
View File
@@ -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()
}
+18
View File
@@ -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()
}
+63
View File
@@ -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.`)
+262
View File
@@ -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.`)
+78
View File
@@ -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 <ip> <max-accounts> [note]')
console.log(' npm run accounts:ip -- remove <ip>')
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()
}
+333
View File
@@ -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}`)
})
+3
View File
@@ -0,0 +1,3 @@
import type { Plugin } from 'vite'
export function gameApiPlugin(): Plugin
+2094
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -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}`)
})
+4955
View File
File diff suppressed because it is too large Load Diff
+795
View File
@@ -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<Screen>('menu')
const [account, setAccount] = useState<Account | null>(null)
const [profile, setProfile] = useState<CharacterProfile | null>(null)
const [authChecked, setAuthChecked] = useState(false)
const [gameMode, setGameMode] = useState<GameMode>(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<RoguelikeVariant>('pve')
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('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<CpuPvpLeaderboardEntry[]>([])
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 (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Database Error</p>
<h1>Character Unavailable</h1>
<p>{error}</p>
</section>
</main>
)
}
if (!authChecked) {
return (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Opening Chronicle</p>
<h1>Loading...</h1>
</section>
</main>
)
}
if (!account || !profile) {
return (
<AuthScreen
onAuthenticated={acceptSession}
serverMessage={serverMessage}
/>
)
}
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 (
<CombatScreen
difficulty={difficulty}
dungeon={dungeon}
profile={profile}
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
startPart={startPart}
onExit={() => {
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 (
<PvPRoguelikeScreen
contentType={pvpContentType}
encounterPool={pvpPool}
gameMode={gameMode}
onExit={() => {
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 (
<main className="game-shell">
<header className="topbar app-header">
<button
className="brand-button"
onClick={() => setScreen('menu')}
type="button"
>
<strong>Menu</strong>
</button>
<div className="character-summary">
<strong>{profile.character.name}</strong>
<small>Level {profile.character.level}</small>
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
<div className="header-xp" title={`${profile.character.experience} total experience`}>
<span style={{ width: `${experiencePercent}%` }} />
</div>
<button className="logout-button" onClick={signOut} type="button">
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
</button>
</div>
</header>
{screen === 'menu' && (
<section className="menu-screen">
<div className="main-menu-grid">
{MENU_ITEMS.map((item) => (
<button
className="menu-card"
key={item.screen}
onClick={() => {
if (item.screen === 'pvp') {
setRoguelikeVariant('pvp')
setScreen('roguelike')
return
}
setScreen(item.screen)
}}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.label}</strong>
<small>{item.description}</small>
</div>
</button>
))}
</div>
</section>
)}
{screen === 'roguelike' && (
<section className="content-screen">
<ScreenHeading
eyebrow="Endless Draft"
title="Roguelike"
onBack={() => setScreen('menu')}
/>
<div className="roguelike-variant-row">
<button
className={`text-button ${roguelikeVariant === 'pve' ? 'active' : ''}`}
onClick={() => setRoguelikeVariant('pve')}
type="button"
>
PvE
</button>
<button
className={`text-button ${roguelikeVariant === 'pvp' ? 'active' : ''}`}
onClick={() => setRoguelikeVariant('pvp')}
type="button"
>
PvP
</button>
</div>
{roguelikeVariant === 'pve' && (
<>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Timing</p>
<h2>Buff Drafts</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('encounter')}
type="button"
>
Every Encounter
</button>
<button
className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('boss')}
type="button"
>
Boss Only
</button>
</div>
</div>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Labels</p>
<h2>Display Mode</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('ability')}
type="button"
>
Ability Names
</button>
<button
className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('slot')}
type="button"
>
Slot Names
</button>
</div>
</div>
<div className="roguelike-mode-grid">
<button
className="menu-card"
onClick={() => {
const baseDungeon = dungeonOptions[0]
setRoguelikeKind('dungeon')
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
setSelectedPart(1)
setScreen('combat')
}}
type="button"
>
<span>D</span>
<strong>Dungeon Roguelike</strong>
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small>
</button>
<button
className="menu-card"
onClick={() => {
const baseRaid = raidOptions[0]
setRoguelikeKind('raid')
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
setSelectedPart(1)
setScreen('combat')
}}
type="button"
>
<span>R</span>
<strong>Raid Roguelike</strong>
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small>
</button>
</div>
</>
)}
{roguelikeVariant === 'pvp' && (
<>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Match Type</p>
<h2>PvP Roguelike</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${pvpContentType === 'dungeon' ? 'active' : ''}`}
onClick={() => setPvpContentType('dungeon')}
type="button"
>
Dungeon
</button>
<button
className={`text-button ${pvpContentType === 'raid' ? 'active' : ''}`}
onClick={() => setPvpContentType('raid')}
type="button"
>
Raid
</button>
</div>
</div>
<div className="menu-card pvp-queue-panel">
<span>{gameMode === 'offline' ? 'C' : 'Q'}</span>
<div>
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
<small>
{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.'}
</small>
</div>
<button
className="text-button"
onClick={() => setScreen('pvp')}
type="button"
>
Start Match
</button>
</div>
<div className="leaderboard-section">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">CPU Leaderboard</p>
<h2>{pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
</div>
</div>
<div className="leaderboard-table">
<div className="leaderboard-header pvp-leaderboard-row">
<strong>Rank</strong>
<strong>Player</strong>
<strong>CPU</strong>
<strong>Clears</strong>
<strong>Result</strong>
</div>
{cpuLeaderboard.map((entry, index) => (
<div className="leaderboard-row pvp-leaderboard-row" key={`${entry.characterName}-${entry.completedAt}`}>
<strong>#{index + 1}</strong>
<span>{entry.characterName}</span>
<span>CPU {entry.cpuDifficulty}</span>
<span>{entry.encountersCleared}</span>
<span>{entry.result}</span>
</div>
))}
{cpuLeaderboard.length === 0 && (
<div className="leaderboard-empty">
No CPU runs recorded yet for this mode.
</div>
)}
</div>
</div>
</>
)}
</section>
)}
{(screen === 'dungeons' || screen === 'raids') && (
<section className="content-screen">
<ScreenHeading
eyebrow="Adventure"
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
onBack={() => setScreen('menu')}
/>
<article className="dungeon-card">
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(activity.name)}
</div>
<div>
<p className="eyebrow">{activity.locationName}</p>
<h2>{activity.name}</h2>
<p>{activity.description}</p>
<div className="tag-row">
<span>Level {activity.recommendedLevel}</span>
<span>{activity.partySize} Players</span>
<span>{selectedDifficulty.name}</span>
<span>Component Level {selectedDifficulty.droppedItemLevel}</span>
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
</div>
</div>
{activityOptions.length > 1 && (
<label className="activity-select">
<span>{activity.contentType === 'raid' ? 'Raid' : 'Dungeon'}</span>
<select
value={activity.id}
onChange={(event) => {
const nextActivityId = Number(event.target.value)
const nextActivity = activityOptions.find((candidate) => candidate.id === nextActivityId)
if (screen === 'raids') setSelectedRaidId(nextActivityId)
else setSelectedDungeonId(nextActivityId)
if (nextActivity?.difficulties[0]) {
setSelectedDifficultyId(nextActivity.difficulties[0].id)
}
}}
>
{activityOptions.map((candidate) => (
<option key={candidate.id} value={candidate.id}>{candidate.name}</option>
))}
</select>
</label>
)}
<div className="part-buttons">
{parts.map((p) => (
<button
key={p.part}
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !p.unlocked}
onClick={() => {
setSelectedPart(p.part)
setCombatContentId(activity.id)
setScreen('combat')
}}
type="button"
>
{p.name}
</button>
))}
</div>
</article>
<div className="difficulty-section compact-difficulty-section">
<div className="difficulty-select-row">
<div>
<p className="eyebrow">Challenge Tier</p>
<h2>Difficulty</h2>
</div>
<label>
<span>Select</span>
<select
value={selectedDifficulty.id}
onChange={(event) => setSelectedDifficultyId(Number(event.target.value))}
>
{activity.difficulties.map((difficulty, index) => (
<option
disabled={profile.character.level < difficulty.unlockLevel}
key={difficulty.id}
value={difficulty.id}
>
{index + 1}. {difficulty.name}
{profile.character.level < difficulty.unlockLevel
? ` - Level ${difficulty.unlockLevel}`
: ''}
</option>
))}
</select>
</label>
</div>
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
<div>
<strong>{selectedDifficulty.name}</strong>
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : selectedDifficulty.description}</small>
</div>
<dl>
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
<div><dt>Components</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
</dl>
</div>
</div>
<div className="loot-preview-section">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">Encounter Rewards</p>
<h2>{selectedDifficulty.name} Loot Tables</h2>
</div>
<button
className="text-button"
onClick={() => setShowLoot((current) => !current)}
type="button"
>
{showLoot ? 'Hide Loot' : 'View Loot'}
</button>
</div>
{showLoot && (
<>
<div className="loot-toolbar">
<label>
<span>Sort</span>
<select value={lootSort} onChange={(event) => setLootSort(event.target.value as 'sequence' | 'boss')}>
<option value="sequence">Encounter order</option>
<option value="boss">Boss name</option>
</select>
</label>
</div>
<p className="section-note">
Bosses roll {lootChanceSlots} loot chances against the listed table, 1-3 materials each
{activity.completionItemLevel
? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
: ''}
</p>
<div className="loot-preview-grid">
{lootPreviewEncounters.map((encounter) => {
const loot = encounter.lootTables.filter(
(entry) => entry.difficultyId === selectedDifficulty.id,
)
return (
<article key={encounter.id}>
<div className="loot-encounter-title">
{encounter.isBoss ? (
<img className="loot-boss-icon" src={encounter.imageUrl} alt={`${encounter.enemyName} icon`} />
) : (
<span>{encounter.sequence}</span>
)}
<div>
<strong>{encounter.enemyName}</strong>
<small>{loot.length > 0 ? `${lootChanceSlots} weighted chances, 1-3 each` : 'No component table'}</small>
</div>
</div>
<div className="loot-items">
{loot.map((item) => (
<div className={`rarity-${item.rarity}`} key={item.id}>
<span>{item.glyph}</span>
<div>
<strong>{item.name}</strong>
<small>{item.slot} - iLvl {item.itemLevel}</small>
</div>
<i>{item.dropWeight}% weight</i>
</div>
))}
</div>
</article>
)
})}
</div>
</>
)}
</div>
<div className="leaderboard-section">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">Efficiency Rankings</p>
<h2>{selectedDifficulty.name} Leaderboard</h2>
</div>
<button
className="text-button"
onClick={() => setShowLeaderboard((current) => !current)}
type="button"
>
{showLeaderboard ? 'Hide Leaderboard' : 'View Leaderboard'}
</button>
</div>
{showLeaderboard && (
<>
<p className="section-note">
{gameMode === 'offline'
? 'Offline runs are not submitted'
: 'Lowest resource spent ranks first'}
</p>
<div className="leaderboard-tabs">
{([
{ 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) => (
<button
key={tab.key}
className={`leaderboard-tab ${leaderboardCategory === tab.key ? 'active' : ''}`}
onClick={() => setLeaderboardCategory(tab.key)}
type="button"
>
{tab.label}
</button>
))}
</div>
<div className="leaderboard-table">
<div className="leaderboard-header">
<span>Rank</span>
<span>Healer</span>
<span>Class</span>
<span>Level</span>
<span>Item Level</span>
<span>Resource</span>
<span>Time</span>
</div>
{activity.leaderboards[leaderboardCategory]
.filter((entry) => entry.difficultyId === selectedDifficulty.id)
.map((entry) => (
<div className="leaderboard-row" key={`${entry.rank}-${entry.completedAt}`}>
<strong>#{entry.rank}</strong>
<span>{entry.characterName}</span>
<span>{entry.className}</span>
<span>{entry.characterLevel}</span>
<span>{entry.averageItemLevel.toFixed(1)}</span>
<strong>{entry.resourceSpent}</strong>
<span>{entry.durationSeconds}s</span>
</div>
))}
{activity.leaderboards[leaderboardCategory].filter(
(entry) => entry.difficultyId === selectedDifficulty.id,
).length === 0 && (
<div className="leaderboard-empty">
{gameMode === 'offline'
? 'Connect with an online character to compete in rankings.'
: 'Complete this difficulty to claim the first ranking.'}
</div>
)}
</div>
</>
)}
</div>
</section>
)}
{screen === 'customize' && (
<CustomizeScreen
profile={profile}
onBack={() => setScreen('menu')}
onSaved={setProfile}
/>
)}
{screen === 'talents' && (
<TalentScreen
profile={profile}
onBack={() => setScreen('menu')}
onUpdated={setProfile}
/>
)}
{screen === 'equipment' && (
<EquipmentScreen
profile={profile}
onBack={() => setScreen('menu')}
onUpdated={setProfile}
/>
)}
{screen === 'settings' && (
<SettingsScreen onBack={() => setScreen('menu')} />
)}
</main>
)
}
function ScreenHeading({
eyebrow,
title,
onBack,
}: {
eyebrow: string
title: string
onBack: () => void
}) {
return (
<div className="screen-heading">
<div>
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)
}
export default App
+11
View File
@@ -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(
<StrictMode>
<AdminScreen onBack={() => window.close()} />
</StrictMode>,
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+766
View File
@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
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<AdminData | null>(null)
const [tab, setTab] = useState<'items' | 'bosses' | 'loot' | 'crafting'>('items')
const [error, setError] = useState('')
const [saving, setSaving] = useState<Record<string, boolean>>({})
useEffect(() => {
fetchJson<AdminData>(`${API}/data`)
.then(setData)
.catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load'))
}, [])
if (error) return <section className="content-screen"><p className="error-message">{error}</p></section>
if (!data) return <section className="content-screen"><p>Loading admin data...</p></section>
return (
<section className="content-screen admin-screen">
<div className="screen-heading">
<div><p className="eyebrow">Developer Tools</p><h1>Admin Panel</h1></div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
<nav className="admin-tabs">
{(['items', 'bosses', 'loot', 'crafting'] as const).map((t) => (
<button key={t} className={`admin-tab ${tab === t ? 'active' : ''}`}
onClick={() => setTab(t)} type="button">
{t === 'items' ? 'Items' : t === 'bosses' ? 'Boss Images' : t === 'loot' ? 'Boss Loot' : 'Crafting'}
</button>
))}
</nav>
{tab === 'items' && <ItemsTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'bosses' && <BossImagesTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'loot' && <LootTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'crafting' && <CraftingTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
</section>
)
}
function ItemsTab({ data, setData, setSaving, saving }: {
data: AdminData | null
setData: React.Dispatch<React.SetStateAction<AdminData | null>>
setSaving: (s: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void
saving: Record<string, boolean>
}) {
if (!data) return null
const [filter, setFilter] = useState('')
const [editId, setEditId] = useState<number | null>(null)
const [form, setForm] = useState<Partial<AdminItem>>({})
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<string, string | number> = {}
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 (
<div className="admin-panel">
<input className="admin-search" placeholder="Search items..." value={filter}
onChange={(e) => setFilter(e.target.value)} />
{Object.entries(groups).map(([slot, items]) => (
<details key={slot} open>
<summary className="admin-group-header">{slot} ({items.length})</summary>
<div className="admin-grid">
{items.map((item) => (
<div key={item.id} className="admin-card">
{editId === item.id ? (
<div className="admin-edit-form">
<label>Glyph <input value={form.glyph ?? item.glyph} onChange={(e) => setForm({ ...form, glyph: e.target.value })} /></label>
<label>Name <input value={form.name ?? item.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></label>
<label>Slot
<select value={form.slot ?? item.slot} onChange={(e) => setForm({ ...form, slot: e.target.value })}>
{['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket', 'component'].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</label>
<label>Rarity
<select value={form.rarity ?? item.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
{['common', 'uncommon', 'rare', 'epic'].map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</label>
<label>iLvl <input type="number" value={form.itemLevel ?? item.itemLevel} onChange={(e) => setForm({ ...form, itemLevel: Number(e.target.value) })} /></label>
<label>Healing <input type="number" value={form.healingPower ?? item.healingPower} onChange={(e) => setForm({ ...form, healingPower: Number(e.target.value) })} /></label>
<label>Resource <input type="number" value={form.maxResourceBonus ?? item.maxResourceBonus} onChange={(e) => setForm({ ...form, maxResourceBonus: Number(e.target.value) })} /></label>
<label>Description <textarea value={form.description ?? item.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label>
<div className="admin-edit-actions">
<button className="primary-button" onClick={() => saveItem(item.id)} disabled={saving[`item-${item.id}`]} type="button">
{saving[`item-${item.id}`] ? 'Saving...' : 'Save'}
</button>
<button className="text-button" onClick={() => { setEditId(null); setForm({}) }} type="button">Cancel</button>
</div>
</div>
) : (
<>
<div className="admin-item-header">
<span className={`admin-glyph rarity-${item.rarity}`}>{item.glyph}</span>
<div>
<strong>{item.name}</strong>
<small className="admin-item-meta">{item.slot} · iLvl {item.itemLevel} · {item.rarity}</small>
</div>
</div>
<p className="admin-item-desc">{item.description}</p>
<div className="admin-item-stats">
<span>+{item.healingPower} healing</span>
<span>+{item.maxResourceBonus} resource</span>
</div>
<button className="text-button" onClick={() => { setEditId(item.id); setForm({}) }} type="button">Edit</button>
</>
)}
</div>
))}
</div>
</details>
))}
</div>
)
}
function BossImagesTab({ data, setData, setSaving, saving }: {
data: AdminData | null
setData: React.Dispatch<React.SetStateAction<AdminData | null>>
setSaving: (s: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void
saving: Record<string, boolean>
}) {
if (!data) return null
async function uploadBossImage(encounterId: number, file: File | undefined) {
if (!file) return
setSaving((prev) => ({ ...prev, [`boss-image-${encounterId}`]: true }))
try {
const imageData = await fileToDataUrl(file)
const result = await fetchJson<{ imageUrl: string }>(`${API}/encounters/${encounterId}/image`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageData }),
})
setData((prev) => prev ? {
...prev,
encounters: prev.encounters.map((encounter) => (
encounter.id === encounterId
? { ...encounter, imageUrl: result.imageUrl }
: encounter
)),
} : prev)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Upload failed')
} finally {
setSaving((prev) => ({ ...prev, [`boss-image-${encounterId}`]: false }))
}
}
const bosses = data.encounters.filter((encounter) => encounter.encounterType === 'boss')
return (
<div className="admin-panel">
<div className="admin-grid boss-image-grid">
{bosses.map((boss) => (
<div key={boss.id} className="admin-card boss-image-card">
<img src={boss.imageUrl} alt={`${boss.enemyName} icon`} />
<div>
<strong>{boss.enemyName}</strong>
<small className="admin-item-meta">Dungeon {boss.dungeonId} · Encounter {boss.sequence}</small>
</div>
<label className="boss-upload-button">
{saving[`boss-image-${boss.id}`] ? 'Uploading...' : 'Upload Image'}
<input
accept="image/png,image/jpeg,image/webp,image/gif"
disabled={saving[`boss-image-${boss.id}`]}
onChange={(event) => uploadBossImage(boss.id, event.target.files?.[0])}
type="file"
/>
</label>
</div>
))}
</div>
</div>
)
}
function LootTab({ data, setData, setSaving, saving }: {
data: AdminData | null
setData: React.Dispatch<React.SetStateAction<AdminData | null>>
setSaving: (s: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void
saving: Record<string, boolean>
}) {
const [encounterId, setEncounterId] = useState(data?.encounters.filter(e => e.encounterType === 'boss')[0]?.id ?? 0)
const [difficultyId, setDifficultyId] = useState(data?.difficulties[0]?.id ?? 0)
const [addItemId, setAddItemId] = useState(0)
const [addDropWeight, setAddDropWeight] = useState(100)
const [addDropChance, setAddDropChance] = useState(1)
const [renameItemId, setRenameItemId] = useState<number | null>(null)
const [renameValue, setRenameValue] = useState('')
const [bossSort, setBossSort] = useState<'dungeon' | 'boss'>('dungeon')
if (!data) return null
const enc = data.encounters.find(e => e.id === encounterId)
const bossOptions = data.encounters
.filter((e) => e.encounterType === 'boss')
.sort((a, b) => bossSort === 'boss'
? a.enemyName.localeCompare(b.enemyName) || a.dungeonId - b.dungeonId || a.sequence - b.sequence
: a.dungeonId - b.dungeonId || a.sequence - b.sequence || a.enemyName.localeCompare(b.enemyName))
const bossLoot = data.encounterLoot.filter(
(l) => l.encounterId === encounterId && l.difficultyId === difficultyId,
)
const items = data.items
function itemName(id: number) { return items.find(i => i.id === id)?.name ?? `#${id}` }
function itemGlyph(id: number) { return items.find(i => i.id === id)?.glyph ?? '?' }
async function renameItem(itemId: number) {
if (renameValue.trim() === '' || renameValue === itemName(itemId)) return
setSaving((prev) => ({ ...prev, [`loot-rename-${itemId}`]: true }))
try {
await fetchJson(`${API}/items/${itemId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: renameValue.trim() }),
})
setData((prev) => prev ? {
...prev,
items: prev.items.map((item) =>
item.id === itemId ? { ...item, name: renameValue.trim() } : item,
),
} : prev)
setRenameItemId(null)
setRenameValue('')
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Rename failed')
} finally {
setSaving((prev) => ({ ...prev, [`loot-rename-${itemId}`]: false }))
}
}
async function deleteLoot(eId: number, dId: number, iId: number) {
try {
await fetchJson(`${API}/encounter-loot/${eId}/${dId}/${iId}`, { method: 'DELETE' })
setData((prev) => prev ? {
...prev,
encounterLoot: prev.encounterLoot.filter(
(l) => !(l.encounterId === eId && l.difficultyId === dId && l.itemId === iId),
),
} : prev)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Delete failed')
}
}
async function addLoot() {
if (!addItemId) return
try {
await fetchJson(`${API}/encounter-loot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ encounterId, itemId: addItemId, difficultyId, dropWeight: addDropWeight, dropChance: addDropChance }),
})
setData((prev) => prev ? {
...prev,
encounterLoot: [
...prev.encounterLoot.filter(
(l) => !(l.encounterId === encounterId && l.difficultyId === difficultyId && l.itemId === addItemId),
),
{ encounterId, itemId: addItemId, difficultyId, dropWeight: addDropWeight, dropChance: addDropChance },
],
} : prev)
setAddItemId(0)
setAddDropWeight(100)
setAddDropChance(1)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Add failed')
}
}
return (
<div className="admin-panel">
<div className="admin-loot-selectors">
<label>Boss
<select value={encounterId} onChange={(e) => setEncounterId(Number(e.target.value))}>
{bossOptions.map((e) => (
<option key={e.id} value={e.id}>{e.enemyName} (dungeon {e.dungeonId})</option>
))}
</select>
</label>
<label>Sort Bosses
<select value={bossSort} onChange={(e) => setBossSort(e.target.value as 'dungeon' | 'boss')}>
<option value="dungeon">Dungeon order</option>
<option value="boss">Boss name</option>
</select>
</label>
<label>Difficulty
<select value={difficultyId} onChange={(e) => setDifficultyId(Number(e.target.value))}>
{data.difficulties.map((d) => (
<option key={d.id} value={d.id}>{d.name} (iLvl {d.droppedItemLevel})</option>
))}
</select>
</label>
</div>
<h3 className="admin-loot-title">
{enc?.enemyName ?? 'Unknown'} {data.difficulties.find(d => d.id === difficultyId)?.name ?? '?'}
</h3>
{bossLoot.length === 0 && <p className="admin-empty">No loot entries for this boss + difficulty.</p>}
<div className="admin-loot-list">
{bossLoot.map((entry) => (
<div key={`${entry.itemId}`} className="admin-loot-row">
<span className={`admin-glyph rarity-${data.items.find(i => i.id === entry.itemId)?.rarity ?? 'common'}`}>
{itemGlyph(entry.itemId)}
</span>
{renameItemId === entry.itemId ? (
<span className="admin-loot-name">
<input
className="admin-rename-input"
ref={(node) => {
if (node) window.requestAnimationFrame(() => node.focus({ preventScroll: true }))
}}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') renameItem(entry.itemId); if (e.key === 'Escape') { setRenameItemId(null); setRenameValue('') } }}
/>
</span>
) : (
<span className="admin-loot-name">{itemName(entry.itemId)}</span>
)}
<span className="admin-loot-weight">Weight: {entry.dropWeight}</span>
<span className="admin-loot-chance">Chance: {(entry.dropChance * 100).toFixed(0)}%</span>
{renameItemId === entry.itemId ? (
<>
<button className="primary-button" disabled={saving[`loot-rename-${entry.itemId}`]} onClick={() => renameItem(entry.itemId)} type="button">
{saving[`loot-rename-${entry.itemId}`] ? 'Saving...' : 'Save'}
</button>
<button className="text-button" onClick={() => { setRenameItemId(null); setRenameValue('') }} type="button">Cancel</button>
</>
) : (
<>
<button className="text-button" onClick={() => { setRenameItemId(entry.itemId); setRenameValue(itemName(entry.itemId)) }} type="button">Rename</button>
<button className="danger-button" onClick={() => deleteLoot(entry.encounterId, entry.difficultyId, entry.itemId)} type="button">X</button>
</>
)}
</div>
))}
</div>
<details className="admin-add-section">
<summary>Add Item to Loot Table</summary>
<div className="admin-add-form">
<label>Item
<select value={addItemId} onChange={(e) => setAddItemId(Number(e.target.value))}>
<option value={0}>Select item...</option>
{data.items.filter((i) => !bossLoot.some((l) => l.itemId === i.id)).map((i) => (
<option key={i.id} value={i.id}>[{i.glyph}] {i.name} ({i.slot})</option>
))}
</select>
</label>
<label>Drop Weight <input type="number" value={addDropWeight} onChange={(e) => setAddDropWeight(Number(e.target.value))} /></label>
<label>Drop Chance <input type="number" min="0" max="1" step="0.05" value={addDropChance} onChange={(e) => setAddDropChance(Number(e.target.value))} /></label>
<button className="primary-button" onClick={addLoot} disabled={!addItemId} type="button">Add</button>
</div>
</details>
</div>
)
}
function CraftingTab({ data, setData, setSaving, saving }: {
data: AdminData | null
setData: React.Dispatch<React.SetStateAction<AdminData | null>>
setSaving: (s: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void
saving: Record<string, boolean>
}) {
const [recipeId, setRecipeId] = useState(data?.craftingRecipes[0]?.id ?? 0)
const [itemLevelFilter, setItemLevelFilter] = useState('all')
const [bossFilterId, setBossFilterId] = useState(0)
const [addItemId, setAddItemId] = useState(0)
const [addQty, setAddQty] = useState(1)
const [outputNameByItem, setOutputNameByItem] = useState<Record<number, string>>({})
if (!data) return null
const bossEncounters = data.encounters.filter((encounter) => encounter.encounterType === 'boss')
const bossLootItemIds = new Set(
data.encounterLoot
.filter((entry) => bossEncounters.some((boss) => boss.id === entry.encounterId))
.map((entry) => entry.itemId),
)
const itemLevels = Array.from(new Set(
data.craftingRecipes
.map((candidate) => data.items.find((item) => item.id === candidate.itemId)?.itemLevel)
.filter((itemLevel): itemLevel is number => itemLevel !== undefined),
)).sort((a, b) => a - b)
const filteredRecipes = data.craftingRecipes.filter((candidate) => {
const item = data.items.find((i) => i.id === candidate.itemId)
if (!item) return false
if (itemLevelFilter !== 'all' && item.itemLevel !== Number(itemLevelFilter)) return false
if (bossFilterId === 0) return true
return candidate.sourceEncounterId === bossFilterId
|| candidate.components.some((component) => data.encounterLoot.some(
(entry) => entry.encounterId === bossFilterId && entry.itemId === component.itemId,
))
})
const recipe = filteredRecipes.find((r) => r.id === recipeId) ?? filteredRecipes[0] ?? null
const outputItem = recipe ? data.items.find((i) => i.id === recipe.itemId) : null
const outputName = outputItem ? outputNameByItem[outputItem.id] ?? outputItem.name : ''
const bossComponentOptions = data.items.filter((item) => (
item.slot === 'component'
&& bossLootItemIds.has(item.id)
&& !recipe?.components.some((component) => component.itemId === item.id)
))
const addComponentIsValid = bossComponentOptions.some((item) => item.id === addItemId)
const items = data.items
function itemName(id: number) { return items.find(i => i.id === id)?.name ?? `#${id}` }
function itemGlyph(id: number) { return items.find(i => i.id === id)?.glyph ?? '?' }
function bossName(id: number | null) {
return id ? bossEncounters.find((boss) => boss.id === id)?.enemyName ?? `Boss #${id}` : 'Any boss'
}
function componentBossNames(itemId: number) {
return (data!).encounterLoot
.filter((entry) => entry.itemId === itemId)
.map((entry) => bossEncounters.find((boss) => boss.id === entry.encounterId)?.enemyName ?? `Boss #${entry.encounterId}`)
.join(', ')
}
async function saveOutputName() {
if (!outputItem || outputName.trim() === '' || outputName === outputItem.name) return
setSaving((prev) => ({ ...prev, [`item-name-${outputItem.id}`]: true }))
try {
await fetchJson(`${API}/items/${outputItem.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: outputName.trim() }),
})
setData((prev) => prev ? {
...prev,
items: prev.items.map((item) => (
item.id === outputItem.id ? { ...item, name: outputName.trim() } : item
)),
} : prev)
setOutputNameByItem((prev) => ({ ...prev, [outputItem.id]: outputName.trim() }))
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Rename failed')
} finally {
setSaving((prev) => ({ ...prev, [`item-name-${outputItem.id}`]: false }))
}
}
async function uploadItemImage(itemId: number, file: File | undefined) {
if (!file) return
setSaving((prev) => ({ ...prev, [`item-image-${itemId}`]: true }))
try {
const imageData = await fileToDataUrl(file)
const result = await fetchJson<{ imageUrl: string }>(`${API}/items/${itemId}/image`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageData }),
})
setData((prev) => prev ? {
...prev,
items: prev.items.map((item) => (
item.id === itemId ? { ...item, imageUrl: result.imageUrl } : item
)),
} : prev)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Upload failed')
} finally {
setSaving((prev) => ({ ...prev, [`item-image-${itemId}`]: false }))
}
}
async function deleteComponent(rId: number, iId: number) {
try {
await fetchJson(`${API}/crafting-recipes/${rId}/components/${iId}`, { method: 'DELETE' })
setData((prev) => prev ? {
...prev,
craftingRecipes: prev.craftingRecipes.map((r) =>
r.id === rId ? { ...r, components: r.components.filter((c) => c.itemId !== iId) } : r,
),
} : prev)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Delete failed')
}
}
async function addComponent() {
if (!addComponentIsValid || !recipe) return
try {
await fetchJson(`${API}/crafting-recipes/${recipe.id}/components`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemId: addItemId, quantity: addQty }),
})
setData((prev) => prev ? {
...prev,
craftingRecipes: prev.craftingRecipes.map((r) =>
r.id === recipe!.id
? { ...r, components: [...r.components.filter((c) => c.itemId !== addItemId), { itemId: addItemId, quantity: addQty }] }
: r,
),
} : prev)
setAddItemId(0)
setAddQty(1)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Add failed')
}
}
async function updateQty(rId: number, iId: number, quantity: number) {
try {
await fetchJson(`${API}/crafting-recipes/${rId}/components`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemId: iId, quantity }),
})
setData((prev) => prev ? {
...prev,
craftingRecipes: prev.craftingRecipes.map((r) =>
r.id === rId ? {
...r,
components: r.components.map((c) => c.itemId === iId ? { ...c, quantity } : c),
} : r,
),
} : prev)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Update failed')
}
}
return (
<div className="admin-panel">
<div className="admin-crafting-filters">
<label>Item Level
<select value={itemLevelFilter} onChange={(e) => setItemLevelFilter(e.target.value)}>
<option value="all">All levels</option>
{itemLevels.map((itemLevel) => (
<option key={itemLevel} value={itemLevel}>iLvl {itemLevel}</option>
))}
</select>
</label>
<label>Boss
<select value={bossFilterId} onChange={(e) => setBossFilterId(Number(e.target.value))}>
<option value={0}>All bosses</option>
{bossEncounters.map((boss) => (
<option key={boss.id} value={boss.id}>{boss.enemyName}</option>
))}
</select>
</label>
<label>Recipe
<select value={recipe?.id ?? 0} onChange={(e) => setRecipeId(Number(e.target.value))}>
{filteredRecipes.length === 0 && <option value={0}>No matching recipes</option>}
{filteredRecipes.map((r) => (
<option key={r.id} value={r.id}>
[{itemGlyph(r.itemId)}] {itemName(r.itemId)} - {bossName(r.sourceEncounterId)}
</option>
))}
</select>
</label>
</div>
{outputItem && (
<div className="admin-recipe-header">
<img className="admin-recipe-image" src={outputItem.imageUrl || '/equipment-placeholder.svg'} alt={`${outputItem.name} icon`} />
<div>
<label className="admin-inline-field">Name
<input
value={outputName}
onChange={(e) => {
if (!outputItem) return
setOutputNameByItem((prev) => ({ ...prev, [outputItem.id]: e.target.value }))
}}
/>
</label>
<small className="admin-item-meta">{outputItem.slot} · iLvl {outputItem.itemLevel} · {outputItem.rarity}</small>
</div>
<div className="admin-recipe-actions">
<button
className="primary-button"
disabled={saving[`item-name-${outputItem.id}`] || outputName.trim() === '' || outputName === outputItem.name}
onClick={saveOutputName}
type="button"
>
{saving[`item-name-${outputItem.id}`] ? 'Saving...' : 'Rename'}
</button>
<label className="boss-upload-button">
{saving[`item-image-${outputItem.id}`] ? 'Uploading...' : 'Upload Image'}
<input
accept="image/png,image/jpeg,image/webp,image/gif"
disabled={saving[`item-image-${outputItem.id}`]}
onChange={(event) => uploadItemImage(outputItem.id, event.target.files?.[0])}
type="file"
/>
</label>
</div>
</div>
)}
<h3 className="admin-loot-title">Required Components</h3>
{(!recipe || recipe.components.length === 0) && (
<p className="admin-empty">No component requirements.</p>
)}
<div className="admin-loot-list">
{recipe?.components.map((comp) => (
<div key={comp.itemId} className="admin-loot-row">
<span className={`admin-glyph rarity-${data.items.find(i => i.id === comp.itemId)?.rarity ?? 'common'}`}>
{itemGlyph(comp.itemId)}
</span>
<span className="admin-loot-name">{itemName(comp.itemId)}</span>
<span className="admin-loot-weight">Qty:
<input className="admin-qty-input" type="number" min="1" value={comp.quantity}
onChange={(e) => updateQty(recipe.id, comp.itemId, Number(e.target.value))} />
</span>
<button className="danger-button" onClick={() => deleteComponent(recipe.id, comp.itemId)} type="button">X</button>
</div>
))}
</div>
<details className="admin-add-section">
<summary>Add Component</summary>
<div className="admin-add-form">
<label>Component
<select value={addItemId} onChange={(e) => setAddItemId(Number(e.target.value))}>
<option value={0}>Select component...</option>
{bossComponentOptions.map((i) => (
<option key={i.id} value={i.id}>[{i.glyph}] {i.name} ({componentBossNames(i.id)})</option>
))}
</select>
</label>
<label>Quantity <input type="number" min="1" value={addQty} onChange={(e) => setAddQty(Number(e.target.value))} /></label>
<button className="primary-button" onClick={addComponent} disabled={!recipe || !addComponentIsValid} type="button">Add</button>
</div>
</details>
</div>
)
}
function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') resolve(reader.result)
else reject(new Error('Unable to read image.'))
})
reader.addEventListener('error', () => reject(reader.error ?? new Error('Unable to read image.')))
reader.readAsDataURL(file)
})
}
function groupBy<T>(items: T[], keyFn: (item: T) => string): Record<string, T[]> {
const groups: Record<string, T[]> = {}
for (const item of items) {
const key = keyFn(item)
if (!groups[key]) groups[key] = []
groups[key].push(item)
}
return groups
}
+193
View File
@@ -0,0 +1,193 @@
import { useState } from 'react'
import {
loginAccount,
registerAccount,
type AuthSession,
} from '../profile'
import {
createOfflineCharacter,
hasOfflineCharacter,
resumeOfflineCharacter,
selectOnlineMode,
} from '../gameRepository'
type Props = {
onAuthenticated: (session: AuthSession) => void
serverMessage?: string
}
export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
const [mode, setMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [characterName, setCharacterName] = useState('')
const [offlineName, setOfflineName] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const offlineCharacterExists = hasOfflineCharacter()
async function submit(event: React.FormEvent) {
event.preventDefault()
setBusy(true)
setMessage('')
try {
selectOnlineMode()
const session = mode === 'login'
? await loginAccount(username, password)
: await registerAccount(username, password, characterName)
onAuthenticated(session)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to authenticate.')
} finally {
setBusy(false)
}
}
function beginOffline() {
setMessage('')
try {
onAuthenticated(createOfflineCharacter(offlineName))
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to create an offline character.')
}
}
function resumeOffline() {
const session = resumeOfflineCharacter()
if (session) onAuthenticated(session)
}
return (
<main className="auth-shell">
<section className="auth-panel">
<div className="auth-brand">
<p className="eyebrow">Healer RPG</p>
<h1>I want to Heal</h1>
<p>
Build your healer, master each dungeon, and compete for the most
efficient clears.
</p>
</div>
<div className="auth-card">
<div className="auth-tabs">
<button
className={mode === 'login' ? 'selected' : ''}
onClick={() => {
setMode('login')
setMessage('')
}}
type="button"
>
Sign In
</button>
<button
className={mode === 'register' ? 'selected' : ''}
onClick={() => {
setMode('register')
setMessage('')
}}
type="button"
>
Create Account
</button>
</div>
<form onSubmit={submit}>
<label>
Username
<input
autoComplete="username"
maxLength={20}
minLength={3}
onChange={(event) => setUsername(event.target.value)}
pattern="[A-Za-z0-9_]+"
required
value={username}
/>
</label>
{mode === 'register' && (
<label>
Character Name
<input
autoComplete="nickname"
maxLength={20}
minLength={2}
onChange={(event) => setCharacterName(event.target.value)}
required
value={characterName}
/>
</label>
)}
<label>
Password
<input
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
maxLength={128}
minLength={10}
onChange={(event) => setPassword(event.target.value)}
required
type="password"
value={password}
/>
</label>
<button className="primary-button" disabled={busy} type="submit">
{busy
? 'Working...'
: mode === 'login'
? 'Enter Chronicle'
: 'Begin Adventure'}
</button>
</form>
<p className={`auth-message ${message ? 'error' : ''}`}>
{message || serverMessage || (
mode === 'register'
? 'The first account keeps the current local character and save.'
: 'Sign in to continue your character.'
)}
</p>
<div className="offline-divider"><span>or</span></div>
<section className="offline-entry">
<div>
<p className="eyebrow">Local Save</p>
<h2>Play Offline</h2>
<p>
No account or connection required. Offline progress stays on
this device and is excluded from online leaderboards.
</p>
</div>
{offlineCharacterExists && (
<button
className="offline-resume-button"
onClick={resumeOffline}
type="button"
>
Continue Offline Character
</button>
)}
<label>
{offlineCharacterExists ? 'New Character Name' : 'Character Name'}
<input
maxLength={20}
minLength={2}
onChange={(event) => setOfflineName(event.target.value)}
placeholder="Mira"
value={offlineName}
/>
</label>
<button
className="text-button offline-new-button"
onClick={beginOffline}
type="button"
>
{offlineCharacterExists ? 'Replace Offline Character' : 'Begin Offline Adventure'}
</button>
</section>
</div>
</section>
</main>
)
}
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
import type { CSSProperties } from 'react'
import {
bindingLabel,
compactBindingLabel,
type ControllerIconStyle,
} from '../input'
const FACE_BUTTONS: Record<ControllerIconStyle, Partial<Record<number, { color: string; label: string }>>> = {
xbox: {
0: { color: '#107c10', label: 'A' },
1: { color: '#d13438', label: 'B' },
2: { color: '#0078d4', label: 'X' },
3: { color: '#ffb900', label: 'Y' },
},
playstation: {
0: { color: '#0070d1', label: '×' },
1: { color: '#df0024', label: '○' },
2: { color: '#f27ab8', label: '□' },
3: { color: '#00a35a', label: '△' },
},
nintendo: {
0: { color: '#e60012', label: 'B' },
1: { color: '#e60012', label: 'A' },
2: { color: '#e60012', label: 'Y' },
3: { color: '#e60012', label: 'X' },
},
}
function faceButtonFor(binding: string, iconStyle: ControllerIconStyle) {
if (!binding.startsWith('Button')) return null
return FACE_BUTTONS[iconStyle][Number(binding.slice(6))] ?? null
}
function FaceIcon({
color,
iconStyle,
label,
title,
}: {
color: string
iconStyle: ControllerIconStyle
label: string
title: string
}) {
return (
<span
aria-label={title}
className={`controller-face-icon controller-face-${iconStyle}`}
role="img"
style={{ '--button-color': color } as CSSProperties}
>
{label}
</span>
)
}
export function ControllerBindingLabel({
binding,
compact = false,
iconStyle,
}: {
binding: string
compact?: boolean
iconStyle: ControllerIconStyle
}) {
const faceButton = faceButtonFor(binding, iconStyle)
const title = bindingLabel(binding, iconStyle)
if (faceButton) {
return (
<FaceIcon
color={faceButton.color}
iconStyle={iconStyle}
label={faceButton.label}
title={title}
/>
)
}
return <>{compact ? compactBindingLabel(binding, iconStyle) : title}</>
}
export function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) {
return (
<span className="controller-style-preview" aria-hidden="true">
{[0, 1, 2, 3].map((button) => {
const faceButton = FACE_BUTTONS[iconStyle][button]
if (!faceButton) return null
return (
<FaceIcon
color={faceButton.color}
iconStyle={iconStyle}
key={button}
label={faceButton.label}
title=""
/>
)
})}
</span>
)
}
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
saveProfile,
type CharacterProfile,
type GameClass,
} from '../profile'
import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen'
type Props = {
profile: CharacterProfile
onBack: () => void
onSaved: (profile: CharacterProfile) => void
}
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class')
const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0)
const [message, setMessage] = useState('')
const [saving, setSaving] = useState(false)
const scrollRef = useRef<number>(0)
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
const abilityMap = useMemo(
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
[gameClass],
)
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
function saveScroll() {
scrollRef.current = window.scrollY
}
function chooseClass(nextClass: GameClass) {
const starterAbilities = nextClass.spells
.filter((ability) => ability.unlockLevel <= profile.character.level)
.slice(0, 5)
.map((ability) => ability.id)
setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
setSelectedSlot(0)
setMessage('')
}
function equipAbility(abilityId: number) {
if (slots.includes(abilityId)) {
setMessage('That ability is already equipped.')
return
}
setSlots((current) =>
current.map((spellId, index) => index === selectedSlot ? abilityId : spellId),
)
setMessage('')
}
function clearSlot() {
setSlots((current) =>
current.map((spellId, index) => index === selectedSlot ? null : spellId),
)
}
async function persistChanges() {
saveScroll()
setSaving(true)
setMessage('')
try {
const updated = await saveProfile(classId, slots)
onSaved(updated)
setMessage('Character saved.')
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to save character.')
} finally {
setSaving(false)
}
}
return (
<section className="content-screen customize-screen">
<div className="screen-heading">
<div>
<p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
{([
{ key: 'equipment', label: 'Equipment' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
] as const).map((tab) => (
<button
aria-selected={activeTab === tab.key}
className={activeTab === tab.key ? 'active' : ''}
key={tab.key}
onClick={() => setActiveTab(tab.key)}
role="tab"
type="button"
>
{tab.label}
</button>
))}
</div>
{activeTab === 'equipment' && (
<EquipmentScreen
embedded
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'talents' && (
<TalentScreen
embedded
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'class' && (
<div className="customize-layout">
<aside className="class-picker">
<p className="eyebrow">Healing Class</p>
{profile.classes.map((candidate) => (
<button
className={candidate.id === classId ? 'active' : ''}
key={candidate.id}
onClick={() => chooseClass(candidate)}
style={{ '--class-color': candidate.themeColor } as React.CSSProperties}
type="button"
>
<span>{candidate.name[0]}</span>
<div>
<strong>{candidate.name}</strong>
<small>{candidate.resourceName}</small>
</div>
</button>
))}
</aside>
<div className="loadout-editor">
<div className="class-detail">
<div
className="class-portrait"
style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}
>
{gameClass.name[0]}
</div>
<div>
<p className="eyebrow">Level {profile.character.level} Healer</p>
<h2>{gameClass.name}</h2>
<p>{gameClass.description}</p>
</div>
</div>
<div className="loadout-heading">
<div>
<p className="eyebrow">Active Loadout</p>
<h2>Ability Bar</h2>
</div>
<span>Select a slot, then choose an ability.</span>
</div>
<div className="ability-slots">
{slots.map((abilityId, index) => {
const ability = abilityId ? abilityMap.get(abilityId) : undefined
return (
<button
className={selectedSlot === index ? 'selected' : ''}
key={index}
onClick={() => setSelectedSlot(index)}
type="button"
>
<kbd>{index + 1}</kbd>
<span>{ability?.glyph ?? '-'}</span>
<strong>{ability?.name ?? 'Empty Slot'}</strong>
</button>
)
})}
</div>
<div className="ability-library-heading">
<div>
<p className="eyebrow">Class Abilities</p>
<h2>Ability Library</h2>
</div>
<button className="text-button" onClick={clearSlot} type="button">Clear Selected Slot</button>
</div>
<div className="ability-library">
{gameClass.spells.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return (
<button
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''}`}
disabled={locked}
key={ability.id}
onClick={() => equipAbility(ability.id)}
type="button"
>
<span>{locked ? 'L' : ability.glyph}</span>
<div>
<strong>{ability.name}</strong>
<small>{ability.description}</small>
</div>
<i>{locked ? `Level ${ability.unlockLevel}` : equipped ? 'Equipped' : `${ability.cost} ${gameClass.resourceName}`}</i>
</button>
)
})}
</div>
<div className="save-row">
<span>{message}</span>
<button
className="primary-button"
disabled={saving}
onClick={persistChanges}
type="button"
>
{saving ? 'Saving...' : 'Save Character'}
</button>
</div>
</div>
</div>
)}
</section>
)
}
+537
View File
@@ -0,0 +1,537 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
breakdownItem,
craftItem,
equipItem,
loadProfile,
type CharacterProfile,
type EquipmentSlot,
type Item,
} from '../profile'
const SLOT_LABELS: Record<EquipmentSlot, string> = {
weapon: 'Weapon',
helmet: 'Helmet',
chest: 'Chest',
gloves: 'Gloves',
boots: 'Boots',
pants: 'Pants',
ring: 'Ring',
necklace: 'Necklace',
trinket: 'Trinket',
component: 'Component',
}
type Props = {
profile: CharacterProfile
onBack?: () => void
onUpdated: (profile: CharacterProfile) => void
embedded?: boolean
}
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
const totalItemCount = profile.inventory.reduce(
(total, item) => total + item.quantity,
0,
)
const firstItem = profile.inventory.find((item) => !item.equipped)
?? profile.inventory[0]
const [selectedItemId, setSelectedItemId] = useState<number | null>(
firstItem?.id ?? null,
)
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null)
const [equipping, setEquipping] = useState(false)
const [breakingDown, setBreakingDown] = useState(false)
const [crafting, setCrafting] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0)
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft)
?? profile.craftingRecipes[0]
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
firstRecipe?.id ?? null,
)
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
const equippedBySlot = useMemo(
() => new Map(
profile.inventory
.filter((item) => item.equipped)
.map((item) => [item.slot, item]),
),
[profile.inventory],
)
const comparisonItem = selectedItem
? equippedBySlot.get(selectedItem.slot)
: undefined
const visibleInventory = useMemo(
() => selectedSlot
? profile.inventory.filter((item) => item.slot === selectedSlot)
: profile.inventory,
[profile.inventory, selectedSlot],
)
const visibleItemCount = visibleInventory.reduce(
(total, item) => total + item.quantity,
0,
)
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
const [levelFilter, setLevelFilter] = useState<number | null>(null)
const availableLevels = useMemo(
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
[profile.craftingRecipes],
)
const filteredRecipes = useMemo(
() => {
let result = [...profile.craftingRecipes]
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
return result
},
[profile.craftingRecipes, slotFilter, levelFilter],
)
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
useEffect(() => {
if (equipmentTab === 'crafting') {
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
}
}, [equipmentTab])
function saveScroll() {
scrollRef.current = window.scrollY
}
async function equipSelected() {
if (!selectedItem || selectedItem.equipped) return
saveScroll()
setEquipping(true)
setMessage('')
try {
const updated = await equipItem(selectedItem.id)
onUpdated(updated)
setMessage(`${selectedItem.name} equipped.`)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to equip item.')
} finally {
setEquipping(false)
}
}
async function breakdownSelected() {
if (!selectedItem) return
saveScroll()
setBreakingDown(true)
setMessage('')
try {
const updated = await breakdownItem(selectedItem.id)
onUpdated(updated)
setMessage(
selectedItem.quantity > 1
? `One duplicate ${selectedItem.name} broken down into components.`
: `${selectedItem.name} broken down into components.`,
)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to break down item.')
} finally {
setBreakingDown(false)
}
}
async function craftSelected() {
if (!selectedRecipe) return
saveScroll()
setCrafting(true)
setMessage('')
try {
const updated = await craftItem(selectedRecipe.id)
onUpdated(updated)
setSelectedItemId(selectedRecipe.item.id)
setMessage(`${selectedRecipe.item.name} crafted.`)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to craft item.')
} finally {
setCrafting(false)
}
}
const content = (
<>
{!embedded && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Loadout</p>
<h1>Equipment</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="gear-summary">
<div className="gear-character">
<span style={{ borderColor: profile.character.themeColor, color: profile.character.themeColor }}>
{profile.character.className[0]}
</span>
<div>
<p className="eyebrow">{profile.character.className}</p>
<h2>{profile.character.name}</h2>
</div>
</div>
<GearStat value={profile.gearStats.averageItemLevel.toFixed(1)} label="Average Item Level" />
<GearStat value={`+${profile.gearStats.healingPower}`} label="Healing Power" />
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div>
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
{equipmentTab === 'equipment' ? (
<>
<section className="item-comparison">
{selectedItem ? (
selectedItem.slot === 'component' ? (
<>
<ItemDetail title="Crafting Component" item={selectedItem} />
<div className="equip-action">
<p className="component-note">Used in crafting.</p>
</div>
</>
) : (
<>
<ItemDetail title={selectedItem.equipped ? 'Selected Equipment' : 'Inventory Item'} item={selectedItem} />
<div className="comparison-arrow">vs</div>
{comparisonItem && comparisonItem.id !== selectedItem.id ? (
<ItemDetail title="Currently Equipped" item={comparisonItem} />
) : (
<div className="item-detail empty-comparison">
<p className="eyebrow">Comparison</p>
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div>
)}
<div className="equip-action">
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button
className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown}
onClick={equipSelected}
type="button"
>
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button>
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button
className="breakdown-button"
disabled={equipping || breakingDown}
onClick={breakdownSelected}
type="button"
>
{breakingDown
? 'Breaking Down...'
: selectedItem.quantity > 1
? 'Break Down Duplicate'
: 'Break Down'}
</button>
)}
</div>
</>
)
) : (
<p>Select an item to inspect it.</p>
)}
</section>
<div className="equipment-layout">
<section className="equipped-panel">
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
<div className="equipment-slots">
{profile.equipmentSlots.map((slot) => {
const item = equippedBySlot.get(slot)
return (
<button
className={`${item ? `rarity-${item.rarity}` : 'empty'} ${selectedSlot === slot ? 'selected-slot' : ''}`}
key={slot}
onClick={() => {
setSelectedSlot(slot)
const firstSlotItem = profile.inventory.find(
(candidate) => candidate.slot === slot,
)
setSelectedItemId(item?.id ?? firstSlotItem?.id ?? null)
}}
type="button"
>
<span>{item?.glyph ?? '-'}</span>
<div>
<strong>{item?.name ?? SLOT_LABELS[slot]}</strong>
<small>{SLOT_LABELS[slot]}{item ? ` - iLvl ${item.itemLevel}` : ' - Empty'}</small>
</div>
<div className="item-status">
{item && <i>Equipped</i>}
</div>
</button>
)
})}
</div>
</section>
<section className="inventory-panel">
<EquipmentHeading
eyebrow="Owned Items"
title={selectedSlot ? `${SLOT_LABELS[selectedSlot]} Inventory` : 'Inventory'}
detail={selectedSlot
? `${visibleItemCount} items - ${visibleInventory.length} types`
: `${totalItemCount} items - ${profile.inventory.length} types`}
/>
{selectedSlot && (
<button
className="inventory-filter-clear"
onClick={() => setSelectedSlot(null)}
type="button"
>
Show All Items
</button>
)}
<div className="inventory-list">
{visibleInventory.map((item) => (
<button
className={`${selectedItemId === item.id ? 'selected' : ''} rarity-${item.rarity}`}
key={item.id}
onClick={() => setSelectedItemId(item.id)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.name}</strong>
<small>{SLOT_LABELS[item.slot]} - Item Level {item.itemLevel}</small>
</div>
<div className="item-status">
{item.equipped && <i>Equipped</i>}
{item.quantity > 1 && <i className="item-quantity">x{item.quantity}</i>}
</div>
</button>
))}
{visibleInventory.length === 0 && (
<p className="inventory-empty">
No {SLOT_LABELS[selectedSlot ?? 'component'].toLowerCase()} items owned.
</p>
)}
</div>
</section>
</div>
</>
) : (
<section className="crafting-panel">
<EquipmentHeading
eyebrow="Crafting"
title="Recipes"
detail={`${filteredRecipes.filter((recipe) => recipe.canCraft).length} ready`}
/>
<div className="crafting-filter-bar">
<select
className="filter-select"
value={slotFilter}
onChange={(e) => setSlotFilter(e.target.value as EquipmentSlot | 'all')}
>
<option value="all">All Slots</option>
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
<option key={slot} value={slot}>{label}</option>
))}
</select>
<select
className="filter-select"
value={levelFilter ?? ''}
onChange={(e) => setLevelFilter(e.target.value === '' ? null : Number(e.target.value))}
>
<option value="">All Levels</option>
{availableLevels.map((level) => (
<option key={level} value={level}>Item Level {level}</option>
))}
</select>
</div>
{filteredRecipes.length === 0 && (
<p className="inventory-empty">No crafting recipes match filters.</p>
)}
{filteredRecipes.length > 0 && (
<div className="crafting-layout">
<div className="crafting-list">
{filteredRecipes.map((recipe) => (
<button
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
key={recipe.id}
onClick={() => setSelectedRecipeId(recipe.id)}
type="button"
>
<span>{recipe.item.glyph}</span>
<div>
<strong>{recipe.item.name}</strong>
<small>
{SLOT_LABELS[recipe.item.slot]} - Item Level {recipe.item.itemLevel}
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
</small>
</div>
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i>
</button>
))}
</div>
{selectedRecipe && (
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
<div className="crafting-components">
{selectedRecipe.components.map((component) => (
<div
className={component.owned >= component.quantity ? 'ready' : 'missing'}
key={component.item.id}
>
<span>{component.item.glyph}</span>
<strong>{component.item.name}</strong>
<i>{component.owned}/{component.quantity}</i>
</div>
))}
</div>
<button
className="primary-button"
disabled={!selectedRecipe.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : 'Craft Item'}
</button>
</div>
)}
</div>
)}
</section>
)}
{profile.setBonuses.length > 0 && (
<section className="set-bonus-panel">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">Set Bonuses</p>
<h2>Raid Sets</h2>
</div>
<button
className="text-button"
onClick={() => setShowSetBonuses((current) => !current)}
type="button"
>
{showSetBonuses ? 'Hide Raid Sets' : 'Show Raid Sets'}
</button>
</div>
{showSetBonuses && (
<div className="set-bonus-list">
{profile.setBonuses.map((bonus) => (
<div className={bonus.active ? 'active' : ''} key={`${bonus.setId}-${bonus.requiredPieces}`}>
<strong>{bonus.requiredPieces} pieces</strong>
<span>{bonus.description}</span>
<i>{bonus.equippedPieces}/{bonus.requiredPieces}</i>
</div>
))}
</div>
)}
</section>
)}
<footer className="equipment-footer">
{message || 'Equipment changes are saved immediately.'}
</footer>
</>
)
if (embedded) {
return <div className="equipment-screen embedded-screen">{content}</div>
}
return (
<section className="content-screen equipment-screen">
{content}
</section>
)
}
function GearStat({ value, label }: { value: string; label: string }) {
return (
<div className="gear-stat">
<strong>{value}</strong>
<span>{label}</span>
</div>
)
}
function EquipmentHeading({
eyebrow,
title,
detail,
}: {
eyebrow: string
title: string
detail?: string
}) {
return (
<div className="equipment-heading">
<div><p className="eyebrow">{eyebrow}</p><h2>{title}</h2></div>
{detail && <span>{detail}</span>}
</div>
)
}
function ItemDetail({ title, item }: { title: string; item: Item }) {
return (
<article className={`item-detail rarity-${item.rarity}`}>
<p className="eyebrow">{title}</p>
<div className="item-title">
<span>{item.glyph}</span>
<div>
<h2>{item.name}</h2>
<small>{SLOT_LABELS[item.slot]} - Item Level {item.itemLevel}</small>
</div>
</div>
<p>{item.description}</p>
{item.quantity > 1 && <p className="owned-quantity">Owned: {item.quantity}</p>}
{item.slot !== 'component' && (
<ul>
<li>+{item.healingPower} Healing Power</li>
<li>+{item.maxResourceBonus} Max Resource</li>
</ul>
)}
</article>
)
}
function ComparisonDelta({
selected,
equipped,
}: {
selected: Item
equipped?: Item
}) {
const healingDelta = selected.healingPower - (equipped?.healingPower ?? 0)
const resourceDelta = selected.maxResourceBonus - (equipped?.maxResourceBonus ?? 0)
return (
<div className="comparison-delta">
<span className={healingDelta >= 0 ? 'positive' : 'negative'}>
{healingDelta >= 0 ? '+' : ''}{healingDelta} Healing
</span>
<span className={resourceDelta >= 0 ? 'positive' : 'negative'}>
{resourceDelta >= 0 ? '+' : ''}{resourceDelta} Resource
</span>
</div>
)
}
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
import { useEffect, useState } from 'react'
import {
ACTION_LABELS,
INPUT_ACTIONS,
useInput,
type InputDevice,
} from '../input'
import {
ControllerBindingLabel,
ControllerStylePreview,
} from './ControllerIcons'
const CONTROLLER_STYLE_LABELS = {
xbox: 'Xbox',
playstation: 'PlayStation',
nintendo: 'Nintendo',
} as const
import { useDualScreen } from '../dualScreen'
import {
getNativeDisplays,
hasNativeDualScreenBridge,
type AndroidDisplay,
} from '../nativeDualScreen'
export function SettingsScreen({ onBack }: { onBack: () => void }) {
const [device, setDevice] = useState<InputDevice>('controller')
const [displayMessage, setDisplayMessage] = useState('')
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
const {
bindings,
capture,
controllerIconStyle,
directPartyTargeting,
beginCapture,
cancelCapture,
resetBindings,
setControllerIconStyle,
setDirectPartyTargeting,
} = useInput()
const {
enabled: dualScreenEnabled,
connected: topDisplayConnected,
setEnabled: setDualScreenEnabled,
openTopDisplay,
} = useDualScreen()
const nativeDualScreen = hasNativeDualScreenBridge()
const directTargetActions = new Set([
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'toggleTargetGroup',
])
const visibleActions = INPUT_ACTIONS.filter((action) => (
directPartyTargeting
? action !== 'previousTarget' && action !== 'nextTarget'
: !directTargetActions.has(action)
))
async function refreshNativeDisplays() {
if (!nativeDualScreen) return
try {
const result = await getNativeDisplays()
setAndroidDisplays(result.displays)
} catch {
setAndroidDisplays([])
}
}
useEffect(() => {
if (!nativeDualScreen) return
getNativeDisplays()
.then((result) => setAndroidDisplays(result.displays))
.catch(() => setAndroidDisplays([]))
}, [nativeDualScreen])
async function launchTopDisplay() {
const opened = await openTopDisplay()
setDisplayMessage(opened
? nativeDualScreen
? 'Android placed the game on the larger display and controls on the smaller display.'
: 'Companion display opened. Move it to the Thor screen you want and select Fullscreen.'
: 'No usable second display was found. Check the Thor display mode and try again.')
await refreshNativeDisplays()
}
return (
<section className="content-screen settings-screen">
<div className="screen-heading">
<div>
<p className="eyebrow">Game Options</p>
<h1>Settings</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
<section className="dual-screen-settings">
<div>
<p className="eyebrow">Display</p>
<h2>AYN Thor Dual-Screen Mode</h2>
<p>
The upper display shows enemy and party health. The lower display
keeps targeting, resources, skills, and cooldowns.
</p>
</div>
<div className="dual-screen-actions">
<button
className={dualScreenEnabled ? 'selected' : ''}
onClick={() => {
setDualScreenEnabled(!dualScreenEnabled)
setDisplayMessage('')
}}
type="button"
>
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
</button>
<button onClick={launchTopDisplay} type="button">
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
</button>
</div>
<small>
{displayMessage || (
topDisplayConnected
? 'The companion display is connected and receiving live combat data.'
: 'Open the companion display before starting combat.'
)}
</small>
{nativeDualScreen && androidDisplays.length > 0 && (
<div className="android-display-list">
{androidDisplays.map((display) => (
<span key={display.id}>
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
{display.width}×{display.height} at {Math.round(display.refreshRate)} Hz
{display.isPresentation ? ' - Presentation' : ''}
</span>
))}
</div>
)}
</section>
<div className="settings-heading">
<div>
<p className="eyebrow">Input</p>
<h2>Keybindings</h2>
</div>
<p>Select an action, then press the new key or controller control.</p>
</div>
<section className="controller-preferences">
<div>
<p className="eyebrow">Targeting</p>
<h3>Direct Party Keybinds</h3>
<p>
Assign party slots directly. In raids, use the group-switch binding
to alternate between members 1-5 and 6-10.
</p>
</div>
<button
aria-pressed={directPartyTargeting}
className={directPartyTargeting ? 'selected' : ''}
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
type="button"
>
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</button>
<div className="controller-icon-options">
<span>Controller Icons</span>
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
<button
aria-pressed={controllerIconStyle === style}
className={controllerIconStyle === style ? 'selected' : ''}
key={style}
onClick={() => setControllerIconStyle(style)}
type="button"
>
<ControllerStylePreview iconStyle={style} />
<span className="controller-style-name">{CONTROLLER_STYLE_LABELS[style]}</span>
</button>
))}
</div>
</section>
<div className="binding-tabs">
<button
className={device === 'controller' ? 'selected' : ''}
onClick={() => setDevice('controller')}
type="button"
>
Controller
</button>
<button
className={device === 'pc' ? 'selected' : ''}
onClick={() => setDevice('pc')}
type="button"
>
PC
</button>
</div>
<div className="binding-list">
{visibleActions.map((action) => (
<button
className={capture?.device === device && capture.action === action ? 'listening' : ''}
key={action}
onClick={() => beginCapture(device, action)}
type="button"
>
<span>{ACTION_LABELS[action]}</span>
<kbd>
{capture?.device === device && capture.action === action
? 'Press a control...'
: (
<ControllerBindingLabel
binding={bindings[device][action]}
iconStyle={controllerIconStyle}
/>
)}
</kbd>
</button>
))}
</div>
<footer className="settings-footer">
<span>Bindings are saved automatically on this device.</span>
<button className="text-button" onClick={() => resetBindings(device)} type="button">
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
</button>
</footer>
{capture && (
<div className="binding-capture" role="dialog" aria-modal="true">
<div>
<p className="eyebrow">Remapping</p>
<h2>{ACTION_LABELS[capture.action]}</h2>
<p>
Press any {capture.device === 'pc' ? 'keyboard key' : 'controller button or move a stick'}.
</p>
<button onClick={cancelCapture} type="button">Cancel</button>
</div>
</div>
)}
</section>
)
}
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useRef, useState } from 'react'
import {
allocateTalent,
resetTalents,
type CharacterProfile,
type Talent,
} from '../profile'
type Props = {
profile: CharacterProfile
onBack?: () => void
onUpdated: (profile: CharacterProfile) => void
embedded?: boolean
}
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
const [resetting, setResetting] = useState(false)
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0)
const gameClass = profile.classes.find(
(candidate) => candidate.id === profile.character.classId,
)!
const classPointsSpent = gameClass.talents.reduce(
(total, talent) => total + talent.rank,
0,
)
const tiers = Array.from(
new Set(gameClass.talents.map((talent) => talent.tier)),
).sort((a, b) => a - b)
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
function saveScroll() {
scrollRef.current = window.scrollY
}
function lowerTierPoints(talent: Talent) {
return gameClass.talents
.filter((candidate) => candidate.tier < talent.tier)
.reduce((total, candidate) => total + candidate.rank, 0)
}
function lockReason(talent: Talent) {
if (talent.rank >= talent.maxRank) return 'Maximum rank'
const requiredTierPoints = (talent.tier - 1) * 5
if (lowerTierPoints(talent) < requiredTierPoints) {
return `Requires ${requiredTierPoints} earlier-tier points`
}
if (talent.prerequisiteTalentId) {
const prerequisite = gameClass.talents.find(
(candidate) => candidate.id === talent.prerequisiteTalentId,
)
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
return `Requires ${talent.prerequisiteName} rank ${talent.prerequisiteRank}`
}
}
if (profile.character.talentPoints <= 0) return 'No points available'
return ''
}
async function purchaseRank(talent: Talent) {
saveScroll()
setBusyTalentId(talent.id)
setMessage('')
try {
const updated = await allocateTalent(talent.id)
onUpdated(updated)
setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.')
} finally {
setBusyTalentId(null)
}
}
async function refundTree() {
saveScroll()
setResetting(true)
setMessage('')
try {
const updated = await resetTalents()
onUpdated(updated)
setMessage('All points in this talent tree were refunded.')
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.')
} finally {
setResetting(false)
}
}
const content = (
<>
{!embedded && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Growth</p>
<h1>Talents</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="talent-toolbar">
<div className="talent-class-summary">
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
{gameClass.name[0]}
</span>
<div>
<p className="eyebrow">{gameClass.name} Tree</p>
<h2>Shape Your Healing Style</h2>
</div>
</div>
<div className="talent-points">
<strong>{profile.character.talentPoints}</strong>
<span>Available</span>
<small>{classPointsSpent} spent in this tree</small>
</div>
</div>
<div className="talent-tree">
{tiers.map((tier) => {
const requiredPoints = (tier - 1) * 5
return (
<section className="talent-tier" key={tier}>
<div className="tier-label">
<span>Tier {tier}</span>
<small>
{tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
</small>
</div>
<div className="tier-talents">
{gameClass.talents
.filter((talent) => talent.tier === tier)
.sort((a, b) => a.branch - b.branch)
.map((talent) => {
const reason = lockReason(talent)
const isBusy = busyTalentId === talent.id
return (
<article
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`}
key={talent.id}
style={{ gridColumn: talent.branch }}
>
<div className="talent-node-header">
<span>{talent.glyph}</span>
<div>
<strong>{talent.name}</strong>
<small>Rank {talent.rank}/{talent.maxRank}</small>
</div>
</div>
<p>{talent.description}</p>
<div className="rank-pips">
{Array.from({ length: talent.maxRank }, (_, index) => (
<i className={index < talent.rank ? 'filled' : ''} key={index} />
))}
</div>
<button
disabled={Boolean(reason) || isBusy}
onClick={() => purchaseRank(talent)}
type="button"
>
{isBusy ? 'Saving...' : reason || 'Add Rank'}
</button>
</article>
)
})}
</div>
</section>
)
})}
</div>
<footer className="talent-footer">
<span>{message || 'Talent changes are saved immediately.'}</span>
<button
className="text-button"
disabled={classPointsSpent === 0 || resetting}
onClick={refundTree}
type="button"
>
{resetting ? 'Refunding...' : 'Reset Tree'}
</button>
</footer>
</>
)
if (embedded) {
return <div className="talent-screen embedded-screen">{content}</div>
}
return (
<section className="content-screen talent-screen">
{content}
</section>
)
}
+447
View File
@@ -0,0 +1,447 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import type { CombatLogEntry, PartyMember, Spell } from './game'
import {
hasNativeDualScreenBridge,
openNativeTopDisplay,
} from './nativeDualScreen'
import {
dispatchExternalGameAction,
type ControllerIconStyle,
type InputAction,
} from './input'
import { ControllerBindingLabel } from './components/ControllerIcons'
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
const SNAPSHOT_KEY = 'ashen-halls-dual-screen-snapshot'
const CHANNEL_NAME = 'ashen-halls-dual-screen'
export type DualScreenCombatState = {
difficultyName: string
dungeonName: string
contentName: string
encounterName: string
encounterDescription: string
encounterHealth: number
encounterMaxHealth: number
encounterIsBoss: boolean
encounterIndex: number
encounterCount: number
party: PartyMember[]
partySize: number
selectedId: string
log: CombatLogEntry[]
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'
resource: number
maxResource: number
resourceName: string
playerIsAlive: boolean
spells: Array<(Spell & { slotIndex: number; remaining: number }) | null>
activeDevice: 'pc' | 'controller'
bindings: Record<InputAction, string>
controllerIconStyle: ControllerIconStyle
directPartyTargeting: boolean
paused: boolean
targetGroup: 0 | 1
}
type DualScreenMessage =
| { type: 'combat-state'; state: DualScreenCombatState }
| { type: 'companion-ready' }
| { type: 'companion-heartbeat' }
| { type: 'control-action'; action: InputAction }
| { type: 'combat-ended' }
type DualScreenContextValue = {
enabled: boolean
connected: boolean
setEnabled: (enabled: boolean) => void
openTopDisplay: () => Promise<boolean>
}
const DualScreenContext = createContext<DualScreenContextValue | null>(null)
function createChannel() {
return typeof BroadcastChannel === 'undefined'
? null
: new BroadcastChannel(CHANNEL_NAME)
}
function saveSnapshot(state: DualScreenCombatState) {
try {
localStorage.setItem(SNAPSHOT_KEY, JSON.stringify({
savedAt: Date.now(),
state,
}))
} catch {
// Live BroadcastChannel updates still work if storage is unavailable.
}
}
function loadRecentSnapshot() {
try {
const snapshot = JSON.parse(localStorage.getItem(SNAPSHOT_KEY) ?? 'null') as {
savedAt: number
state: DualScreenCombatState
} | null
if (!snapshot || Date.now() - snapshot.savedAt > 15000) return null
return snapshot.state
} catch {
return null
}
}
export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true',
)
const [connected, setConnected] = useState(false)
const heartbeatRef = useRef(0)
const setEnabled = useCallback((nextEnabled: boolean) => {
localStorage.setItem(STORAGE_KEY, String(nextEnabled))
setEnabledState(nextEnabled)
if (!nextEnabled) setConnected(false)
}, [])
const openTopDisplay = useCallback(async () => {
setEnabled(true)
if (hasNativeDualScreenBridge()) {
try {
await openNativeTopDisplay()
return true
} catch {
return false
}
}
const url = new URL(window.location.href)
url.searchParams.set('display', 'bottom')
const companion = window.open(
url.toString(),
'ashen-halls-top-display',
'popup=yes,width=1280,height=720',
)
companion?.focus()
return Boolean(companion)
}, [setEnabled])
useEffect(() => {
const channel = createChannel()
if (!channel) return
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (
event.data.type !== 'companion-ready'
&& event.data.type !== 'companion-heartbeat'
) return
heartbeatRef.current = Date.now()
setConnected(true)
}
const timer = window.setInterval(() => {
if (Date.now() - heartbeatRef.current > 3500) setConnected(false)
}, 1000)
return () => {
window.clearInterval(timer)
channel.close()
}
}, [])
const value = useMemo(
() => ({ enabled, connected, setEnabled, openTopDisplay }),
[connected, enabled, openTopDisplay, setEnabled],
)
return (
<DualScreenContext.Provider value={value}>
{children}
</DualScreenContext.Provider>
)
}
export function useDualScreen() {
const context = useContext(DualScreenContext)
if (!context) throw new Error('useDualScreen must be used inside DualScreenProvider')
return context
}
export function useDualScreenPublisher(
state: DualScreenCombatState,
enabled: boolean,
) {
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (!enabled) return
const channel = createChannel()
if (!channel) return
const publish = () => channel.postMessage({
type: 'combat-state',
state: stateRef.current,
} satisfies DualScreenMessage)
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
if (event.data.type === 'control-action') {
dispatchExternalGameAction(event.data.action, 'controller')
}
}
publish()
return () => {
channel.postMessage({ type: 'combat-ended' } satisfies DualScreenMessage)
channel.close()
}
}, [enabled])
useEffect(() => {
if (!enabled) return
saveSnapshot(state)
const channel = createChannel()
channel?.postMessage({ type: 'combat-state', state } satisfies DualScreenMessage)
channel?.close()
}, [enabled, state])
}
export function DualScreenBottomDisplay() {
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
useEffect(() => {
const channel = createChannel()
if (!channel) return
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'combat-state') setState(event.data.state)
if (event.data.type === 'combat-ended') setState(null)
}
announce()
const timer = window.setInterval(() => {
channel.postMessage({ type: 'companion-heartbeat' } satisfies DualScreenMessage)
}, 1500)
return () => {
window.clearInterval(timer)
channel.close()
}
}, [])
function sendAction(action: InputAction) {
const channel = createChannel()
channel?.postMessage({ type: 'control-action', action } satisfies DualScreenMessage)
channel?.close()
}
if (!state) {
return (
<main className="dual-bottom-display dual-bottom-waiting">
<section>
<p className="eyebrow">Dual-Screen HUD</p>
<h1>Waiting for Combat</h1>
<p>Choose a dungeon or raid on the upper screen.</p>
</section>
</main>
)
}
return (
<main className="dual-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{state.difficultyName} {state.contentName}</p>
<h1>{state.dungeonName}</h1>
</div>
<div className="dual-controls-progress">
<span>Encounter {state.encounterIndex + 1}/{state.encounterCount}</span>
</div>
</header>
<section className="dual-controls-resource">
<div>
<p className="eyebrow">Active Target</p>
<strong>
{state.party.find((member) => member.id === state.selectedId)?.name ?? 'No Target'}
</strong>
</div>
<div className="dual-controls-mana">
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
<div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
</div>
</div>
</section>
<div className={`dual-controls-targets ${state.directPartyTargeting ? 'direct' : ''}`}>
{state.directPartyTargeting ? (
<>
{([1, 2, 3, 4, 5] as const).map((slot) => {
const action = `targetParty${slot}` as InputAction
const memberIndex = slot - 1 + (state.partySize === 10 ? state.targetGroup * 5 : 0)
return (
<button onClick={() => sendAction(action)} type="button" key={action}>
<ControllerBindingLabel
binding={state.bindings[action]}
iconStyle={state.controllerIconStyle}
/>{' '}
{state.party[memberIndex]?.name ?? `Party ${slot}`}
</button>
)
})}
{state.partySize === 10 && (
<button onClick={() => sendAction('toggleTargetGroup')} type="button">
<ControllerBindingLabel
binding={state.bindings.toggleTargetGroup}
iconStyle={state.controllerIconStyle}
/>{' '}
Party Group {state.targetGroup + 1}
</button>
)}
</>
) : (
<>
<button onClick={() => sendAction('previousTarget')} type="button">
<ControllerBindingLabel
binding={state.bindings.previousTarget}
iconStyle={state.controllerIconStyle}
/> Previous Target
</button>
<button onClick={() => sendAction('nextTarget')} type="button">
Next Target <ControllerBindingLabel
binding={state.bindings.nextTarget}
iconStyle={state.controllerIconStyle}
/>
</button>
</>
)}
</div>
<section className="dual-controls-spells">
{state.spells.map((spell, slotIndex) => {
if (!spell) {
return (
<div className="spell empty-spell" key={`empty-${slotIndex}`}>
<kbd>{slotIndex + 1}</kbd>
<strong>Empty</strong>
</div>
)
}
const action = `ability${slotIndex + 1}` as InputAction
return (
<button
className="spell"
disabled={
!state.playerIsAlive
|| state.resource < spell.cost
|| spell.remaining > 0
|| state.status !== 'playing'
|| state.paused
}
key={spell.id}
onClick={() => sendAction(action)}
type="button"
>
<kbd>
<ControllerBindingLabel
binding={state.bindings[action]}
compact
iconStyle={state.controllerIconStyle}
/>
</kbd>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
<strong>{spell.name}</strong>
<small>{spell.cost} {state.resourceName}</small>
{spell.remaining > 0 && <i>{spell.remaining.toFixed(1)}</i>}
</button>
)
})}
</section>
</main>
)
}
export function DualScreenTopCombat({
state,
onSelectTarget,
}: {
state: DualScreenCombatState
onSelectTarget: (id: string) => void
}) {
const enemyPercent = Math.max(
0,
(state.encounterHealth / state.encounterMaxHealth) * 100,
)
return (
<div className="dual-top-main">
<section className="dual-top-enemy">
<div className="enemy-portrait" aria-hidden="true">
{state.encounterIsBoss ? 'B' : 'M'}
</div>
<div className="enemy-info">
<div className="bar-label">
<strong>{state.encounterName}</strong>
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
</div>
<div className="bar enemy-health">
<span style={{ width: `${enemyPercent}%` }} />
</div>
<p>{state.encounterDescription}</p>
</div>
</section>
<section className="dual-top-party">
<div className={`dual-top-party-grid ${state.partySize === 10 ? 'raid' : ''}`}>
{state.party.map((member, index) => {
const partySlot = index + 1 + (state.directPartyTargeting && state.partySize === 10 ? state.targetGroup * 5 : 0)
const targetAction = `targetParty${partySlot}` as InputAction
const targetBinding = state.directPartyTargeting ? state.bindings[targetAction] : null
return (
<button
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
key={member.id}
onClick={() => onSelectTarget(member.id)}
type="button"
>
<div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong>
<small>{Math.ceil(member.health)} / {member.maxHealth}</small>
</div>
<div className="bar member-health">
<span style={{ width: `${(member.health / member.maxHealth) * 100}%` }} />
{member.shield > 0 && (
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
)}
</div>
{state.directPartyTargeting && targetBinding && (
<div className="member-target-key">
<ControllerBindingLabel
binding={targetBinding}
iconStyle={state.controllerIconStyle}
/>
</div>
)}
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew</span>}
{member.debuff && <span className="debuff">{member.debuff}</span>}
</div>
</button>
)
})}
</div>
</section>
<footer className="dual-top-log">
{state.log.slice(0, 3).map((entry) => (
<span className={entry.tone} key={entry.id}>{entry.text}</span>
))}
</footer>
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
export type Role = 'Tank' | 'Healer' | 'Damage'
export type PartyMember = {
id: string
name: string
role: Role
health: number
maxHealth: number
shield: number
hotTicks: number
debuff?: string
debuffTicks?: number
poisonStacks?: number
maxHealthPenaltyTicks?: number
healingReductionTicks?: number
}
export type Spell = {
id: string
key: string
name: string
description: string
cost: number
cooldown: number
power: number
glyph: string
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse'
}
export type Encounter = {
id: string
enemyName: string
description: string
maxHealth: number
damage: number
tankDamage: number
partyDamage: number
isBoss: boolean
}
export type CombatLogEntry = {
id: number
text: string
tone: 'system' | 'heal' | 'danger' | 'loot'
}
export const INITIAL_PARTY: PartyMember[] = [
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
{ id: 'kael', name: 'Kael', role: 'Damage', health: 105, maxHealth: 105, shield: 0, hotTicks: 0 },
{ id: 'ves', name: 'Ves', role: 'Damage', health: 95, maxHealth: 95, shield: 0, hotTicks: 0 },
{ id: 'orin', name: 'Orin', role: 'Damage', health: 110, maxHealth: 110, shield: 0, hotTicks: 0 },
]
export const RAID_PARTY: PartyMember[] = [
{ id: 'brann', name: 'Brann', role: 'Tank', health: 165, maxHealth: 165, shield: 0, hotTicks: 0 },
{ id: 'tala', name: 'Tala', role: 'Tank', health: 155, maxHealth: 155, shield: 0, hotTicks: 0 },
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
{ id: 'seren', name: 'Seren', role: 'Healer', health: 102, maxHealth: 102, shield: 0, hotTicks: 0 },
{ id: 'kael', name: 'Kael', role: 'Damage', health: 105, maxHealth: 105, shield: 0, hotTicks: 0 },
{ id: 'ves', name: 'Ves', role: 'Damage', health: 95, maxHealth: 95, shield: 0, hotTicks: 0 },
{ id: 'orin', name: 'Orin', role: 'Damage', health: 110, maxHealth: 110, shield: 0, hotTicks: 0 },
{ id: 'lyra', name: 'Lyra', role: 'Damage', health: 98, maxHealth: 98, shield: 0, hotTicks: 0 },
{ id: 'dax', name: 'Dax', role: 'Damage', health: 108, maxHealth: 108, shield: 0, hotTicks: 0 },
{ id: 'nyx', name: 'Nyx', role: 'Damage', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
]
export const SPELLS: Spell[] = [
{
id: 'mend',
key: '1',
name: 'Mend',
description: 'A fast, efficient single-target heal.',
cost: 5,
cooldown: 0.5,
power: 30,
glyph: '+',
kind: 'direct',
},
{
id: 'renew',
key: '2',
name: 'Renew',
description: 'Heals now and continues healing over time.',
cost: 7,
cooldown: 0.5,
power: 12,
glyph: '~',
kind: 'hot',
},
{
id: 'radiance',
key: '3',
name: 'Radiance',
description: 'Restores health to every living party member.',
cost: 12,
cooldown: 8,
power: 18,
glyph: '*',
kind: 'group',
},
{
id: 'ward',
key: '4',
name: 'Sun Ward',
description: 'Places a damage-absorbing shield on your target.',
cost: 8,
cooldown: 7,
power: 36,
glyph: 'O',
kind: 'shield',
},
{
id: 'purify',
key: '5',
name: 'Purify',
description: 'Removes a harmful effect and restores a little health.',
cost: 5,
cooldown: 5,
power: 10,
glyph: 'x',
kind: 'cleanse',
},
]
export const ENCOUNTERS: Encounter[] = [
{
id: 'ashfang-pack',
enemyName: 'Ashfang Pack',
description: 'Three beasts snap at random party members.',
maxHealth: 390,
damage: 13,
tankDamage: 7,
partyDamage: 24,
isBoss: false,
},
{
id: 'cinder-adepts',
enemyName: 'Cinder Adepts',
description: 'Cultists pressure the tank while throwing embers into the group.',
maxHealth: 470,
damage: 16,
tankDamage: 10,
partyDamage: 25,
isBoss: false,
},
{
id: 'warden-vhal',
enemyName: 'Warden Vhal',
description: 'Boss: cleanse Searing Mark and prepare group healing for Cinder Pulse.',
maxHealth: 820,
damage: 18,
tankDamage: 13,
partyDamage: 27,
isBoss: true,
},
]
+946
View File
@@ -0,0 +1,946 @@
import starterProfile from './offline-starter-profile.json'
import type {
AuthSession,
CharacterProfile,
DungeonReward,
LootRoll,
Item,
EquipmentSlot,
} from './profile'
export type GameMode = 'online' | 'offline'
export interface GameRepository {
loadSession(): Promise<AuthSession>
register(username: string, password: string, characterName: string): Promise<AuthSession>
login(username: string, password: string): Promise<AuthSession>
logout(): Promise<void>
loadProfile(): Promise<CharacterProfile>
saveProfile(classId: number, abilitySlots: Array<number | null>): Promise<CharacterProfile>
completeDungeon(
dungeonId: number,
difficultyId: number,
resourceSpent: number,
durationSeconds: number,
completedPart?: number,
startPart?: number,
partDurationSeconds?: [number, number, number],
): Promise<DungeonReward>
completeRoguelike(
dungeonId: number,
difficultyId: number,
encountersCleared: number,
resourceSpent: number,
durationSeconds: number,
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level'
},
): Promise<DungeonReward>
allocateTalent(talentId: number): Promise<CharacterProfile>
resetTalents(): Promise<CharacterProfile>
equipItem(itemId: number): Promise<CharacterProfile>
discardExtraItem(itemId: number): Promise<CharacterProfile>
breakdownItem(itemId: number): Promise<CharacterProfile>
craftItem(recipeId: number): Promise<CharacterProfile>
rollEncounterLoot(
encounterId: number,
difficultyId: number,
runToken: string,
): Promise<LootRoll>
}
type CharacterData = {
level: number
experience: number
talentPoints: number
abilitySlots: Array<number | null>
talentRanks: Record<string, number>
inventory: Item[]
}
type OfflineSave = {
version: 3
characterName: string
activeClassId: number
completedDungeonParts: number
completedRaidPhases: number
characters: Record<number, CharacterData>
lootRolls: Record<string, LootRoll>
}
const modeKey = 'chronicle.gameMode'
const offlineSaveKey = 'chronicle.offlineSave.v1'
const offlineAccount = { id: -1, username: 'Offline' }
function clone<T>(value: T): T {
return structuredClone(value)
}
function readMode(): GameMode {
return localStorage.getItem(modeKey) === 'offline' ? 'offline' : 'online'
}
function writeMode(mode: GameMode) {
localStorage.setItem(modeKey, mode)
}
function readOfflineSave(): OfflineSave | null {
const serialized = localStorage.getItem(offlineSaveKey)
if (!serialized) return null
try {
const raw = JSON.parse(serialized)
if (raw.version === 3) return raw as OfflineSave
if (raw.version === 2) return migrateV2ToV3(raw)
if (raw.version === 1) return migrateV1ToV2(raw)
return null
} catch {
return null
}
}
function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }): OfflineSave {
const p = v1.profile
const classes = [1, 2, 3]
const characters: Record<number, CharacterData> = {}
for (const cid of classes) {
const gameClass = p.classes.find((c) => c.id === cid)!
const talentRanks: Record<string, number> = {}
for (const t of gameClass.talents) {
talentRanks[String(t.id)] = t.rank
}
characters[cid] = {
level: cid === p.character.classId ? p.character.level : 1,
experience: cid === p.character.classId ? p.character.experience : 0,
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
abilitySlots: cid === p.character.classId ? [...p.abilitySlots] : [],
talentRanks,
inventory: cid === p.character.classId ? clone(p.inventory) : [],
}
}
const v2: OfflineSave = {
version: 3,
characterName: p.character.name,
activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts,
completedRaidPhases: p.completedRaidPhases ?? 0,
characters,
lootRolls: v1.lootRolls ?? {},
}
localStorage.setItem(offlineSaveKey, JSON.stringify(v2))
return v2
}
function migrateV2ToV3(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
const v3: OfflineSave = {
...v2,
version: 3,
completedRaidPhases: 0,
}
localStorage.setItem(offlineSaveKey, JSON.stringify(v3))
return v3
}
function writeOfflineSave(save: OfflineSave) {
localStorage.setItem(offlineSaveKey, JSON.stringify(save))
}
function requireOfflineSave(): OfflineSave {
const save = readOfflineSave()
if (!save) throw new Error('No offline character exists yet.')
return save
}
function buildProfile(save: OfflineSave): CharacterProfile {
const static_ = clone(starterProfile) as CharacterProfile
const cd = save.characters[save.activeClassId]
const gameClass = static_.classes.find((c) => c.id === save.activeClassId)!
static_.character.name = save.characterName
static_.character.level = cd.level
static_.character.experience = cd.experience
static_.character.talentPoints = cd.talentPoints
static_.character.classId = gameClass.id
static_.character.classSlug = gameClass.slug
static_.character.className = gameClass.name
static_.character.resourceName = gameClass.resourceName
static_.character.maxResource = gameClass.maxResource
static_.character.themeColor = gameClass.themeColor
static_.character.classDescription = gameClass.description
static_.character.currentLevelExperience = experienceForLevel(cd.level)
static_.character.nextLevelExperience = cd.level >= static_.maxLevel
? experienceForLevel(static_.maxLevel)
: experienceForLevel(cd.level + 1)
static_.abilitySlots = cd.abilitySlots
for (const c of static_.classes) {
for (const t of c.talents) {
t.rank = cd.talentRanks[String(t.id)] ?? 0
}
}
static_.allocatedTalentPoints = gameClass.talents.reduce((s, t) => s + t.rank, 0)
static_.inventory = cd.inventory
updateGearStats(static_)
updateSetBonuses(static_)
updateCraftingRecipes(static_)
static_.completedDungeonParts = save.completedDungeonParts
static_.completedRaidPhases = save.completedRaidPhases
return static_
}
function updateGearStats(profile: CharacterProfile) {
const equipped = profile.inventory.filter((item) => item.equipped)
profile.gearStats = {
averageItemLevel: profile.equipmentSlots.length === 0
? 0
: equipped.reduce((total, item) => total + item.itemLevel, 0)
/ profile.equipmentSlots.length,
healingPower: equipped.reduce((total, item) => total + item.healingPower, 0),
maxResourceBonus: equipped.reduce(
(total, item) => total + item.maxResourceBonus,
0,
),
}
}
function updateSetBonuses(profile: CharacterProfile) {
const equippedSetCounts = new Map<number, number>()
for (const item of profile.inventory) {
if (!item.equipped || !item.setId) continue
equippedSetCounts.set(item.setId, (equippedSetCounts.get(item.setId) ?? 0) + 1)
}
profile.setBonuses = (profile.setBonuses ?? []).map((bonus) => {
const equippedPieces = equippedSetCounts.get(bonus.setId) ?? 0
return {
...bonus,
equippedPieces,
active: equippedPieces >= bonus.requiredPieces,
}
})
}
function updateCraftingRecipes(profile: CharacterProfile) {
const owned = new Map(profile.inventory.map((item) => [item.id, item.quantity]))
profile.craftingRecipes = (profile.craftingRecipes ?? []).map((recipe) => {
const components = recipe.components.map((component) => ({
...component,
owned: owned.get(component.item.id) ?? 0,
}))
return {
...recipe,
components,
canCraft: components.every((component) => component.owned >= component.quantity),
}
})
}
function addInventoryItem(inventory: Item[], item: Omit<Item, 'quantity' | 'equipped'>, quantity: number) {
const existing = inventory.find((candidate) => candidate.id === item.id)
if (existing) {
existing.quantity += quantity
return { duplicate: true, quantityAfter: existing.quantity }
}
inventory.push({
...item,
quantity,
equipped: false,
})
return { duplicate: false, quantityAfter: quantity }
}
function experienceForLevel(level: number) {
return (level - 1) * (level - 1) * 100
}
function scaledPvpBossExperience(
startingExperience: number,
startingLevel: number,
bossesCleared: number,
maxLevel: number,
) {
let experience = startingExperience
let level = startingLevel
const maxExperience = experienceForLevel(maxLevel)
for (let bossIndex = 0; bossIndex < bossesCleared && experience < maxExperience; bossIndex += 1) {
const currentLevelFloor = experienceForLevel(level)
const nextLevelExperience = level >= maxLevel
? maxExperience
: experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1
}
}
return { experience, level }
}
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
5: { id: 601, slug: 'basic-component', name: 'Basic Component', itemLevel: 5, glyph: '◇', description: 'A standard crafting component.' },
10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined crafting component.' },
15: { id: 603, slug: 'advanced-component', name: 'Advanced Component', itemLevel: 15, glyph: '◉', description: 'An advanced crafting component.' },
20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
}
type WindowWithApiBase = Window & {
CAPACITOR_API_BASE_URL?: string
}
function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined {
const levels = Object.keys(COMPONENT_ITEMS).map(Number).sort((a, b) => a - b)
let best = levels[0]
for (const level of levels) {
if (level <= itemLevel) best = level
}
return COMPONENT_ITEMS[best]
}
function componentDropQuantity(itemLevel: number) {
const tier = Math.max(0, Math.floor((itemLevel - 5) / 5))
const secondChance = Math.min(0.85, 0.35 + tier * 0.12)
const thirdChance = Math.min(0.6, 0.1 + tier * 0.1)
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0)
}
function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]): T {
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 getApiBaseUrl(): string {
const browserWindow = typeof window === 'undefined'
? undefined
: window as WindowWithApiBase
if (browserWindow?.CAPACITOR_API_BASE_URL) {
return browserWindow.CAPACITOR_API_BASE_URL
}
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) {
return import.meta.env.VITE_API_BASE_URL
}
return ''
}
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = getApiBaseUrl()
const url = baseUrl ? `${baseUrl}${path}` : path
const response = await fetch(url, init)
const body = await response.json()
if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.')
return body
}
const serverRepository: GameRepository = {
loadSession: () => requestJson('/api/auth/session'),
register: (username, password, characterName) =>
requestJson('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, characterName }),
}),
login: (username, password) =>
requestJson('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
}),
logout: async () => {
await requestJson('/api/auth/logout', { method: 'POST' })
},
loadProfile: () => requestJson('/api/profile'),
saveProfile: (classId, abilitySlots) =>
requestJson('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ classId, abilitySlots }),
}),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
requestJson(`/api/dungeons/${dungeonId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds }),
}),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
requestJson('/api/roguelike/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, ...options }),
}),
allocateTalent: (talentId) =>
requestJson(`/api/talents/${talentId}/allocate`, { method: 'POST' }),
resetTalents: () =>
requestJson('/api/talents/reset', { method: 'POST' }),
equipItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/equip`, { method: 'POST' }),
discardExtraItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/discard-extra`, { method: 'POST' }),
breakdownItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/breakdown`, { method: 'POST' }),
craftItem: (recipeId) =>
requestJson(`/api/crafting/recipes/${recipeId}/craft`, { method: 'POST' }),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
requestJson(`/api/encounters/${encounterId}/loot-roll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, runToken }),
}),
}
function emptyCharacterData(classId: number): CharacterData {
const static_ = clone(starterProfile) as CharacterProfile
const gc = static_.classes.find((c) => c.id === classId)!
const talentRanks: Record<string, number> = {}
for (const t of gc.talents) talentRanks[String(t.id)] = 0
const starterItemIds = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109]
const inventory: Item[] = static_.inventory.filter((i) => starterItemIds.includes(i.id)).map((i) => ({ ...i }))
const startingAbilitySlots: Array<number | null> = gc.spells
.filter((s) => s.unlockLevel === 1)
.slice(0, 5)
.map((s) => s.id)
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null)
return {
level: 1,
experience: 0,
talentPoints: 1,
abilitySlots: startingAbilitySlots,
talentRanks,
inventory,
}
}
const offlineRepository: GameRepository = {
async loadSession() {
const save = readOfflineSave()
return {
account: save ? offlineAccount : null,
profile: save ? buildProfile(save) : null,
}
},
async register() {
throw new Error('Account registration requires online mode.')
},
async login() {
throw new Error('Account login requires online mode.')
},
async logout() {
writeMode('online')
},
async loadProfile() {
return buildProfile(requireOfflineSave())
},
async saveProfile(classId, abilitySlots) {
const save = requireOfflineSave()
const static_ = clone(starterProfile) as CharacterProfile
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
if (!gameClass) throw new Error('Selected class does not exist.')
const slots = abilitySlots.slice(0, 6)
while (slots.length < 6) slots.push(null)
const selectedIds = slots.filter((id): id is number => id !== null)
if (new Set(selectedIds).size !== selectedIds.length) {
throw new Error('The same ability cannot be equipped twice.')
}
const activeChar = save.characters[save.activeClassId]
const validIds = new Set(
gameClass.spells
.filter((spell) => spell.unlockLevel <= activeChar.level)
.map((spell) => spell.id),
)
if (selectedIds.some((id) => !validIds.has(id))) {
throw new Error('One or more abilities are locked or belong to another class.')
}
if (!save.characters[classId]) {
save.characters[classId] = emptyCharacterData(classId)
}
save.characters[classId].abilitySlots = slots
save.activeClassId = classId
writeOfflineSave(save)
return buildProfile(save)
},
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
void startPart
void partDurationSeconds
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
throw new Error('The run resource total is invalid.')
}
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.')
}
const save = requireOfflineSave()
const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find(
(candidate) => candidate.id === difficultyId,
)
if (!dungeon || !difficulty) {
throw new Error('That difficulty is not available for this dungeon.')
}
const cd = save.characters[save.activeClassId]
if (cd.level < difficulty.unlockLevel) {
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
}
const previousLevel = cd.level
const previousExperience = cd.experience
const partCount = completedPart ?? 1
const experienceReward = Math.round(
dungeon.experienceReward * difficulty.experienceMultiplier * partCount,
)
const maxExperience = experienceForLevel(profile.maxLevel)
const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
let newLevel = previousLevel
while (
newLevel < profile.maxLevel
&& experienceForLevel(newLevel + 1) <= newExperience
) {
newLevel += 1
}
const levelsGained = newLevel - previousLevel
const gameClass = profile.classes.find(
(candidate) => candidate.id === save.activeClassId,
)!
const unlockedAbilities = gameClass.spells
.filter(
(spell) =>
spell.unlockLevel > previousLevel && spell.unlockLevel <= newLevel,
)
.map(({ id, name, unlockLevel, glyph }) => ({ id, name, unlockLevel, glyph }))
cd.experience = newExperience
cd.level = newLevel
cd.talentPoints = Math.min(
profile.maxTalentPoints,
cd.talentPoints + levelsGained,
)
if (dungeon.contentType === 'raid') {
save.completedRaidPhases = Math.max(save.completedRaidPhases, partCount)
} else {
save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount)
}
let bonusItem: DungeonReward['bonusItem'] = null
if ((startPart ?? 1) === 1 && partCount >= 3 && dungeon.completionLoot.length > 0) {
const targetItemLevel = dungeon.completionItemLevel ?? difficulty.droppedItemLevel + 3
const eligibleLoot = dungeon.completionLoot.filter(
(item) => item.itemLevel >= targetItemLevel,
)
if (eligibleLoot.length > 0) {
const rewardItemLevel = Math.min(...eligibleLoot.map((item) => item.itemLevel))
const rewardPool = eligibleLoot.filter((item) => item.itemLevel === rewardItemLevel)
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
const existing = profile.inventory.find((item) => item.id === selected.id)
const duplicate = Boolean(existing)
let quantityAfter = 1
if (existing) {
existing.quantity += 1
quantityAfter = existing.quantity
} else {
profile.inventory.push({
...selected,
quantity: 1,
equipped: false,
})
}
cd.inventory = profile.inventory
bonusItem = { ...selected, duplicate, quantityAfter }
}
}
writeOfflineSave(save)
const updatedProfile = buildProfile(save)
return {
dungeonName: dungeon.name,
difficultyName: difficulty.name,
droppedItemLevel: difficulty.droppedItemLevel,
experienceGained: newExperience - previousExperience,
previousLevel,
newLevel,
levelsGained,
talentPointsGained: levelsGained,
resourceSpent,
durationSeconds,
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities,
bonusItem,
profile: updatedProfile,
}
},
async completeRoguelike(dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) {
if (!Number.isInteger(encountersCleared) || encountersCleared < 0) {
throw new Error('The roguelike progress total is invalid.')
}
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
throw new Error('The run resource total is invalid.')
}
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.')
}
const save = requireOfflineSave()
const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find(
(candidate) => candidate.id === difficultyId,
)
if (!dungeon || !difficulty) {
throw new Error('That difficulty is not available for this roguelike.')
}
const cd = save.characters[save.activeClassId]
if (cd.level < difficulty.unlockLevel) {
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
}
const previousLevel = cd.level
const previousExperience = cd.experience
const maxExperience = experienceForLevel(profile.maxLevel)
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel)
: null
const newExperience = scaledReward
? scaledReward.experience
: Math.min(
previousExperience
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)),
maxExperience,
)
let newLevel = scaledReward?.level ?? previousLevel
while (
newLevel < profile.maxLevel
&& experienceForLevel(newLevel + 1) <= newExperience
) {
newLevel += 1
}
const levelsGained = newLevel - previousLevel
const gameClass = profile.classes.find(
(candidate) => candidate.id === save.activeClassId,
)!
const unlockedAbilities = gameClass.spells
.filter(
(spell) =>
spell.unlockLevel > previousLevel && spell.unlockLevel <= newLevel,
)
.map(({ id, name, unlockLevel, glyph }) => ({ id, name, unlockLevel, glyph }))
cd.experience = newExperience
cd.level = newLevel
cd.talentPoints = Math.min(
profile.maxTalentPoints,
cd.talentPoints + levelsGained,
)
writeOfflineSave(save)
const updatedProfile = buildProfile(save)
return {
dungeonName: `${dungeon.name} Roguelike`,
difficultyName: difficulty.name,
droppedItemLevel: difficulty.droppedItemLevel,
experienceGained: newExperience - previousExperience,
previousLevel,
newLevel,
levelsGained,
talentPointsGained: levelsGained,
resourceSpent,
durationSeconds,
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities,
bonusItem: null,
profile: updatedProfile,
}
},
async allocateTalent(talentId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find(
(candidate) => candidate.id === save.activeClassId,
)!
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
if (!talent) throw new Error('That talent does not belong to the active class.')
if (cd.talentPoints <= 0) {
throw new Error('No talent points are available.')
}
if (talent.rank >= talent.maxRank) {
throw new Error('That talent is already at maximum rank.')
}
const lowerTierPoints = gameClass.talents
.filter((candidate) => candidate.tier < talent.tier)
.reduce((total, candidate) => total + candidate.rank, 0)
const requiredTierPoints = (talent.tier - 1) * 5
if (lowerTierPoints < requiredTierPoints) {
throw new Error(`Spend ${requiredTierPoints} points in earlier tiers first.`)
}
if (talent.prerequisiteTalentId) {
const prerequisite = gameClass.talents.find(
(candidate) => candidate.id === talent.prerequisiteTalentId,
)
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
throw new Error(
`The prerequisite talent requires rank ${talent.prerequisiteRank}.`,
)
}
}
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
cd.talentPoints -= 1
writeOfflineSave(save)
return buildProfile(save)
},
async resetTalents() {
const save = requireOfflineSave()
const profile = buildProfile(save)
const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find(
(candidate) => candidate.id === save.activeClassId,
)!
const refunded = gameClass.talents.reduce(
(total, talent) => total + talent.rank,
0,
)
for (const talent of gameClass.talents) {
cd.talentRanks[String(talent.id)] = 0
}
cd.talentPoints = Math.min(
profile.maxTalentPoints,
cd.talentPoints + refunded,
)
writeOfflineSave(save)
return buildProfile(save)
},
async equipItem(itemId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
for (const candidate of profile.inventory) {
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
}
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async discardExtraItem(itemId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
item.quantity -= 1
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async breakdownItem(itemId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
if (item.slot === 'component') throw new Error('Components cannot be broken down.')
if (item.equipped && item.quantity <= 1) {
throw new Error('Equipped items cannot be broken down.')
}
const componentTemplate = componentForItemLevel(item.itemLevel)
if (!componentTemplate) throw new Error('No component type exists for this item level.')
if (item.quantity <= 1) {
profile.inventory.splice(profile.inventory.indexOf(item), 1)
} else {
item.quantity -= 1
}
const existing = profile.inventory.find((c) => c.id === componentTemplate.id)
const count = Math.floor(Math.random() * 3) + 1
if (existing) {
existing.quantity += count
} else {
profile.inventory.push({
id: componentTemplate.id,
slug: componentTemplate.slug,
name: componentTemplate.name,
slot: 'component' as EquipmentSlot,
rarity: 'common' as const,
itemLevel: componentTemplate.itemLevel,
healingPower: 0,
maxResourceBonus: 0,
glyph: componentTemplate.glyph,
description: componentTemplate.description,
quantity: count,
equipped: false,
})
}
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async craftItem(recipeId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.')
const missing = recipe.components.find((component) => component.owned < component.quantity)
if (missing) {
throw new Error(`Need ${missing.quantity} ${missing.item.name} to craft this item.`)
}
for (const component of recipe.components) {
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to craft this item.`)
owned.quantity -= component.quantity
}
for (let index = profile.inventory.length - 1; index >= 0; index -= 1) {
if (profile.inventory[index].quantity <= 0) profile.inventory.splice(index, 1)
}
addInventoryItem(profile.inventory, recipe.item, 1)
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async rollEncounterLoot(encounterId, difficultyId, runToken) {
if (runToken.length < 8 || runToken.length > 100) {
throw new Error('A valid dungeon run token is required.')
}
const save = requireOfflineSave()
const rollKey = `${runToken}:${encounterId}:${difficultyId}`
if (save.lootRolls[rollKey]) return clone(save.lootRolls[rollKey])
const profile = buildProfile(save)
const encounter = profile.dungeons
.flatMap((dungeon) => dungeon.encounters)
.find((candidate) => candidate.id === encounterId)
const difficulty = profile.dungeons
.flatMap((dungeon) => dungeon.difficulties)
.find((candidate) => candidate.id === difficultyId)
const entries = encounter?.lootTables.filter(
(entry) => entry.difficultyId === difficultyId,
) ?? []
if (!encounter || !difficulty || entries.length === 0) {
throw new Error('This encounter has no configured loot.')
}
const dropChance = entries[0].dropChance
const items: LootRoll['items'] = []
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
const dungeon = profile.dungeons.find((candidate) =>
candidate.encounters.some((dungeonEncounter) => dungeonEncounter.id === encounterId),
)
const lootChanceSlots = dungeon?.contentType === 'raid' ? 8 : 5
for (let index = 0; index < lootChanceSlots; index += 1) {
if (Math.random() >= dropChance) continue
const selected = rollWeightedLootEntry(entries)
const current = selectedQuantities.get(selected.id)
selectedQuantities.set(selected.id, {
entry: selected,
quantity: (current?.quantity ?? 0) + componentDropQuantity(difficulty.droppedItemLevel),
})
}
for (const { entry, quantity } of selectedQuantities.values()) {
const {
encounterId: _encounterId,
difficultyId: _difficultyId,
dropWeight: _dropWeight,
dropChance: _dropChance,
...rolledItem
} = entry
void _encounterId
void _difficultyId
void _dropWeight
void _dropChance
const added = addInventoryItem(profile.inventory, {
...rolledItem,
slot: rolledItem.slot as EquipmentSlot,
rarity: rolledItem.rarity as Item['rarity'],
}, quantity)
items.push({
...rolledItem,
slot: rolledItem.slot as EquipmentSlot,
rarity: rolledItem.rarity as Item['rarity'],
quantity,
duplicate: added.duplicate,
quantityAfter: added.quantityAfter,
})
}
const item = items[0] ?? null
const result: LootRoll = {
encounterId,
encounterName: encounter.enemyName,
difficultyId,
difficultyName: difficulty.name,
dropChance,
dropped: items.length > 0,
item,
items,
awarded: Boolean(item),
duplicate: items.some((candidate) => candidate.duplicate),
quantityAfter: item?.quantityAfter ?? 0,
}
save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return clone(result)
},
}
export function getGameMode(): GameMode {
return readMode()
}
export function selectOnlineMode() {
writeMode('online')
}
export function createOfflineCharacter(characterName: string): AuthSession {
const name = characterName.trim() || 'Mira'
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.')
}
const characters: Record<number, CharacterData> = {}
for (const cid of [1, 2, 3]) {
characters[cid] = emptyCharacterData(cid)
}
const save: OfflineSave = {
version: 3,
characterName: name,
activeClassId: 1,
completedDungeonParts: 0,
completedRaidPhases: 0,
characters,
lootRolls: {},
}
writeOfflineSave(save)
writeMode('offline')
return { account: offlineAccount, profile: buildProfile(save) }
}
export function resumeOfflineCharacter(): AuthSession | null {
const save = readOfflineSave()
if (!save) return null
writeMode('offline')
return { account: offlineAccount, profile: buildProfile(save) }
}
export function hasOfflineCharacter(): boolean {
return readOfflineSave() !== null
}
export function activeGameRepository(): GameRepository {
return readMode() === 'offline' ? offlineRepository : serverRepository
}
+15
View File
@@ -0,0 +1,15 @@
:root {
background:
linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)),
repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px);
color: #f4eed8;
font-family: 'VT323', Consolas, monospace;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
+716
View File
@@ -0,0 +1,716 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
export type InputDevice = 'pc' | 'controller'
export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo'
export const INPUT_ACTIONS = [
'navigateUp',
'navigateDown',
'navigateLeft',
'navigateRight',
'confirm',
'back',
'ability1',
'ability2',
'ability3',
'ability4',
'ability5',
'ability6',
'previousTarget',
'nextTarget',
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'toggleTargetGroup',
'pause',
] as const
export type InputAction = typeof INPUT_ACTIONS[number]
export type InputBindings = Record<InputAction, string>
export const ACTION_LABELS: Record<InputAction, string> = {
navigateUp: 'Navigate Up',
navigateDown: 'Navigate Down',
navigateLeft: 'Navigate Left',
navigateRight: 'Navigate Right',
confirm: 'Confirm / Select',
back: 'Back',
ability1: 'Ability Slot 1',
ability2: 'Ability Slot 2',
ability3: 'Ability Slot 3',
ability4: 'Ability Slot 4',
ability5: 'Ability Slot 5',
ability6: 'Ability Slot 6',
previousTarget: 'Previous Party Target',
nextTarget: 'Next Party Target',
targetParty1: 'Target Party Member 1',
targetParty2: 'Target Party Member 2',
targetParty3: 'Target Party Member 3',
targetParty4: 'Target Party Member 4',
targetParty5: 'Target Party Member 5',
toggleTargetGroup: 'Switch Raid Target Group',
pause: 'Pause Menu',
}
export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
pc: {
navigateUp: 'ArrowUp',
navigateDown: 'ArrowDown',
navigateLeft: 'ArrowLeft',
navigateRight: 'ArrowRight',
confirm: 'Enter',
back: 'Escape',
ability1: 'Digit1',
ability2: 'Digit2',
ability3: 'Digit3',
ability4: 'Digit4',
ability5: 'Digit5',
ability6: 'Digit6',
previousTarget: 'KeyQ',
nextTarget: 'KeyE',
targetParty1: 'F1',
targetParty2: 'F2',
targetParty3: 'F3',
targetParty4: 'F4',
targetParty5: 'F5',
toggleTargetGroup: 'Tab',
pause: 'Escape',
},
controller: {
navigateUp: 'Axis1-',
navigateDown: 'Axis1+',
navigateLeft: 'Axis0-',
navigateRight: 'Axis0+',
confirm: 'Button0',
back: 'Button1',
ability1: 'Button3',
ability2: 'Button2',
ability3: 'Button0',
ability4: 'Button1',
ability5: 'Button5',
ability6: 'Button7',
previousTarget: 'Button14',
nextTarget: 'Button15',
targetParty1: 'Button14',
targetParty2: 'Button12',
targetParty3: 'Button15',
targetParty4: 'Button13',
targetParty5: 'Button4',
toggleTargetGroup: 'Button6',
pause: 'Button9',
},
}
const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
type CaptureState = {
device: InputDevice
action: InputAction
} | null
type InputContextValue = {
bindings: Record<InputDevice, InputBindings>
capture: CaptureState
lastDevice: InputDevice
controllerIconStyle: ControllerIconStyle
directPartyTargeting: boolean
beginCapture: (device: InputDevice, action: InputAction) => void
cancelCapture: () => void
resetBindings: (device: InputDevice) => void
setControllerIconStyle: (style: ControllerIconStyle) => void
setDirectPartyTargeting: (enabled: boolean) => void
}
const InputContext = createContext<InputContextValue | null>(null)
function loadBindings(): Record<InputDevice, InputBindings> {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
const controller = { ...DEFAULT_BINDINGS.controller, ...saved.controller }
const usesLegacyAbilityDefaults = [
'Button2',
'Button3',
'Button4',
'Button5',
'Button6',
'Button7',
].every((binding, index) => (
controller[`ability${index + 1}` as InputAction] === binding
))
if (usesLegacyAbilityDefaults) {
Object.assign(controller, {
ability1: DEFAULT_BINDINGS.controller.ability1,
ability2: DEFAULT_BINDINGS.controller.ability2,
ability3: DEFAULT_BINDINGS.controller.ability3,
ability4: DEFAULT_BINDINGS.controller.ability4,
ability5: DEFAULT_BINDINGS.controller.ability5,
ability6: DEFAULT_BINDINGS.controller.ability6,
})
}
return {
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
controller,
}
} catch {
return structuredClone(DEFAULT_BINDINGS)
}
}
function loadPreferences() {
try {
const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as {
controllerIconStyle?: ControllerIconStyle
directPartyTargeting?: boolean
}
return {
controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
directPartyTargeting: saved.directPartyTargeting ?? false,
}
} catch {
return {
controllerIconStyle: 'xbox' as ControllerIconStyle,
directPartyTargeting: false,
}
}
}
function isTextInput(element: Element | null): element is HTMLInputElement | HTMLTextAreaElement {
return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement
}
function bindingGroup(action: InputAction) {
if (action.startsWith('ability')) return 'abilities'
if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting'
if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting'
if (action === 'pause') return 'pause'
return 'navigation'
}
function isVisible(element: HTMLElement) {
return element.getClientRects().length > 0
}
function focusableElements() {
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
const scope: ParentNode = keyboard ?? pauseMenu ?? document
return Array.from(
scope.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
),
).filter(isVisible)
}
export function focusFirstControl() {
const first = focusableElements()[0]
first?.focus({ preventScroll: true })
return first
}
function moveFocus(action: InputAction) {
const candidates = focusableElements()
if (candidates.length === 0) return
const current = document.activeElement instanceof HTMLElement
&& candidates.includes(document.activeElement)
? document.activeElement
: null
if (!current) {
focusFirstControl()
return
}
const currentRect = current.getBoundingClientRect()
const currentX = currentRect.left + currentRect.width / 2
const currentY = currentRect.top + currentRect.height / 2
const vertical = action === 'navigateUp' || action === 'navigateDown'
const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1
const ranked = candidates
.filter((candidate) => candidate !== current)
.map((candidate) => {
const rect = candidate.getBoundingClientRect()
const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2
const primary = vertical ? y - currentY : x - currentX
const secondary = vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)
return { candidate, primary, score: Math.abs(primary) + secondary * 2.5 }
})
.filter(({ primary }) => Math.sign(primary) === direction)
.sort((a, b) => a.score - b.score)
const next = ranked[0]?.candidate
if (!next) return
next.focus({ preventScroll: true })
next.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
}
const BUTTON_LABELS: Record<number, string> = {
0: 'A / Cross',
1: 'B / Circle',
2: 'X / Square',
3: 'Y / Triangle',
4: 'Left Bumper',
5: 'Right Bumper',
6: 'Left Trigger',
7: 'Right Trigger',
8: 'View / Select',
9: 'Menu / Start',
10: 'Left Stick',
11: 'Right Stick',
12: 'D-Pad Up',
13: 'D-Pad Down',
14: 'D-Pad Left',
15: 'D-Pad Right',
16: 'Home',
}
const KEY_LABELS: Record<string, string> = {
ArrowUp: 'Up Arrow',
ArrowDown: 'Down Arrow',
ArrowLeft: 'Left Arrow',
ArrowRight: 'Right Arrow',
Enter: 'Enter',
Escape: 'Escape',
Space: 'Space',
}
export function bindingLabel(binding: string, iconStyle: ControllerIconStyle = 'xbox') {
if (binding.startsWith('Button')) {
const button = Number(binding.slice(6))
const faceLabels: Record<ControllerIconStyle, Partial<Record<number, string>>> = {
xbox: { 0: 'A', 1: 'B', 2: 'X', 3: 'Y' },
playstation: { 0: '×', 1: '○', 2: '□', 3: '△' },
nintendo: { 0: 'B', 1: 'A', 2: 'Y', 3: 'X' },
}
const shoulderLabels: Record<ControllerIconStyle, Partial<Record<number, string>>> = {
xbox: { 4: 'LB', 5: 'RB', 6: 'LT', 7: 'RT', 8: 'View', 9: 'Start' },
playstation: { 4: 'L1', 5: 'R1', 6: 'L2', 7: 'R2', 8: 'Share', 9: 'Options' },
nintendo: { 4: 'L', 5: 'R', 6: 'ZL', 7: 'ZR', 8: 'Minus', 9: 'Plus' },
}
return faceLabels[iconStyle][button]
?? shoulderLabels[iconStyle][button]
?? BUTTON_LABELS[button]
?? `Button ${button}`
}
if (binding.startsWith('Axis')) {
const axis = Number(binding.slice(4, -1))
const direction = binding.endsWith('-') ? '-' : '+'
const labels: Record<string, string> = {
'0-': 'Left Stick Left',
'0+': 'Left Stick Right',
'1-': 'Left Stick Up',
'1+': 'Left Stick Down',
'2-': 'Right Stick Left',
'2+': 'Right Stick Right',
'3-': 'Right Stick Up',
'3+': 'Right Stick Down',
}
return labels[`${axis}${direction}`] ?? `Axis ${axis} ${direction}`
}
if (KEY_LABELS[binding]) return KEY_LABELS[binding]
if (binding.startsWith('Key') || binding.startsWith('Digit')) return binding.slice(-1)
return binding
}
export function compactBindingLabel(
binding: string,
iconStyle: ControllerIconStyle = 'xbox',
) {
const controllerLabels: Record<string, string> = {
Button14: 'D-Pad Left',
Button15: 'D-Pad Right',
}
return controllerLabels[binding] ?? bindingLabel(binding, iconStyle)
}
function gamepadTokens(gamepad: Gamepad) {
const tokens = new Set<string>()
gamepad.buttons.forEach((button, index) => {
if (button.pressed || button.value > 0.65) tokens.add(`Button${index}`)
})
gamepad.axes.forEach((value, index) => {
if (value < -0.65) tokens.add(`Axis${index}-`)
if (value > 0.65) tokens.add(`Axis${index}+`)
})
return tokens
}
function setInputValue(input: HTMLInputElement | HTMLTextAreaElement, nextValue: string) {
const prototype = input instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
setter?.call(input, nextValue)
input.dispatchEvent(new Event('input', { bubbles: true }))
}
export function InputProvider({ children }: { children: ReactNode }) {
const [bindings, setBindings] = useState(loadBindings)
const [capture, setCapture] = useState<CaptureState>(null)
const [lastDevice, setLastDevice] = useState<InputDevice>('pc')
const [preferences, setPreferences] = useState(loadPreferences)
const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null)
const [keyboardShift, setKeyboardShift] = useState(false)
const bindingsRef = useRef(bindings)
const preferencesRef = useRef(preferences)
const captureRef = useRef(capture)
const keyboardInputRef = useRef(keyboardInput)
const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({})
useEffect(() => {
bindingsRef.current = bindings
localStorage.setItem(STORAGE_KEY, JSON.stringify(bindings))
}, [bindings])
useEffect(() => {
localStorage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify(preferences))
preferencesRef.current = preferences
}, [preferences])
useEffect(() => {
captureRef.current = capture
}, [capture])
useEffect(() => {
keyboardInputRef.current = keyboardInput
}, [keyboardInput])
const assignBinding = useCallback((device: InputDevice, action: InputAction, token: string) => {
setBindings((current) => {
const nextDevice = { ...current[device] }
const previousToken = nextDevice[action]
const collision = INPUT_ACTIONS.find(
(candidate) => (
candidate !== action
&& bindingGroup(candidate) === bindingGroup(action)
&& nextDevice[candidate] === token
),
)
if (collision) nextDevice[collision] = previousToken
nextDevice[action] = token
return { ...current, [device]: nextDevice }
})
setCapture(null)
}, [])
const closeKeyboard = useCallback(() => {
const input = keyboardInputRef.current
setKeyboardInput(null)
window.requestAnimationFrame(() => input?.focus({ preventScroll: true }))
}, [])
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
setLastDevice(device)
document.documentElement.dataset.inputDevice = device
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
if (action.startsWith('navigate')) {
if (!combatActive) moveFocus(action)
} else if (action === 'confirm') {
const active = document.activeElement
if (isTextInput(active)) {
setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl())
} else if (active instanceof HTMLElement && active.matches('button, [role="button"]')) {
active.click()
} else {
focusFirstControl()
}
} else if (action === 'back') {
if (keyboardInputRef.current) {
closeKeyboard()
} else if (!combatActive) {
const backButton = Array.from(
document.querySelectorAll<HTMLButtonElement>('.back-button:not(:disabled)'),
).find(isVisible)
backButton?.click()
}
}
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
}))
}, [closeKeyboard])
const dispatchControllerToken = useCallback((token: string, repeat = false) => {
if (captureRef.current?.device === 'controller') {
if (!repeat) assignBinding('controller', captureRef.current.action, token)
return
}
if (captureRef.current) return
const combatActive = Boolean(
document.querySelector('[data-combat-active="true"]'),
)
const menuDpadActions: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
const directTargetActions = [
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'toggleTargetGroup',
] satisfies InputAction[]
const combatPriority = [
'pause',
'ability1',
'ability2',
'ability3',
'ability4',
'ability5',
'ability6',
'previousTarget',
'nextTarget',
'navigateUp',
'navigateDown',
'navigateLeft',
'navigateRight',
] satisfies InputAction[]
const action = combatActive && preferencesRef.current.directPartyTargeting
? [...directTargetActions, ...combatPriority].find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
: combatActive && menuDpadActions[token]
? menuDpadActions[token]
: !combatActive && menuDpadActions[token]
? menuDpadActions[token]
: (combatActive ? combatPriority : INPUT_ACTIONS).find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
if (!action) return
if (repeat && !action.startsWith('navigate')) return
dispatchAction(action, 'controller')
}, [assignBinding, dispatchAction])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const active = document.activeElement
if (captureRef.current?.device === 'pc') {
event.preventDefault()
if (event.code === 'Escape') {
setCapture(null)
return
}
assignBinding('pc', captureRef.current.action, event.code)
return
}
if (captureRef.current || (isTextInput(active) && !keyboardInputRef.current)) return
const action = INPUT_ACTIONS.find(
(candidate) => bindingsRef.current.pc[candidate] === event.code,
)
if (!action) return
event.preventDefault()
if (event.repeat && !action.startsWith('navigate')) return
dispatchAction(action, 'pc')
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [assignBinding, dispatchAction])
useEffect(() => {
const listener = (event: Event) => {
const detail = (event as CustomEvent<{ token: string; repeat?: boolean }>).detail
dispatchControllerToken(detail.token, Boolean(detail.repeat))
}
window.addEventListener(NATIVE_CONTROLLER_EVENT, listener)
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
}, [dispatchControllerToken])
useEffect(() => {
const ensureFocus = () => {
const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return
if (
document.activeElement === document.body
&& !keyboardInputRef.current
&& !captureRef.current
) {
focusFirstControl()
}
}
const observer = new MutationObserver(() => {
window.requestAnimationFrame(ensureFocus)
})
observer.observe(document.getElementById('root') ?? document.body, {
childList: true,
subtree: true,
})
window.requestAnimationFrame(ensureFocus)
return () => observer.disconnect()
}, [])
useEffect(() => {
let frame = 0
const poll = (time: number) => {
const gamepad = Array.from(navigator.getGamepads?.() ?? []).find(Boolean)
const currentTokens = gamepad ? gamepadTokens(gamepad) : new Set<string>()
const previousTokens = previousTokensRef.current
currentTokens.forEach((token) => {
const pressed = !previousTokens.has(token)
if (pressed && captureRef.current?.device === 'controller') {
assignBinding('controller', captureRef.current.action, token)
return
}
if (captureRef.current) return
const action = INPUT_ACTIONS.find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
const canRepeat = action?.startsWith('navigate') ?? false
const nextRepeat = repeatRef.current[token] ?? 0
if (pressed || (canRepeat && time >= nextRepeat)) {
dispatchControllerToken(token, !pressed)
repeatRef.current[token] = time + (pressed ? 360 : 125)
}
})
Object.keys(repeatRef.current).forEach((token) => {
if (!currentTokens.has(token)) delete repeatRef.current[token]
})
previousTokensRef.current = currentTokens
frame = window.requestAnimationFrame(poll)
}
frame = window.requestAnimationFrame(poll)
return () => window.cancelAnimationFrame(frame)
}, [assignBinding, dispatchControllerToken])
const contextValue = useMemo<InputContextValue>(() => ({
bindings,
capture,
lastDevice,
controllerIconStyle: preferences.controllerIconStyle,
directPartyTargeting: preferences.directPartyTargeting,
beginCapture: (device, action) => setCapture({ device, action }),
cancelCapture: () => setCapture(null),
resetBindings: (device) => setBindings((current) => ({
...current,
[device]: { ...DEFAULT_BINDINGS[device] },
})),
setControllerIconStyle: (controllerIconStyle) => setPreferences((current) => ({
...current,
controllerIconStyle,
})),
setDirectPartyTargeting: (directPartyTargeting) => setPreferences((current) => ({
...current,
directPartyTargeting,
})),
}), [bindings, capture, lastDevice, preferences])
function typeKeyboardKey(key: string) {
if (!keyboardInput) return
const start = keyboardInput.selectionStart ?? keyboardInput.value.length
const end = keyboardInput.selectionEnd ?? start
if (key === 'backspace') {
const from = start === end ? Math.max(0, start - 1) : start
setInputValue(keyboardInput, keyboardInput.value.slice(0, from) + keyboardInput.value.slice(end))
window.requestAnimationFrame(() => keyboardInput.setSelectionRange(from, from))
return
}
const value = key === 'space' ? ' ' : keyboardShift ? key.toUpperCase() : key
const next = keyboardInput.value.slice(0, start) + value + keyboardInput.value.slice(end)
const maxLength = keyboardInput.maxLength > 0 ? keyboardInput.maxLength : Number.POSITIVE_INFINITY
const limited = next.slice(0, maxLength)
setInputValue(keyboardInput, limited)
const cursor = Math.min(start + value.length, limited.length)
window.requestAnimationFrame(() => keyboardInput.setSelectionRange(cursor, cursor))
}
const keyboardKeys = [
...'1234567890',
...'qwertyuiop',
...'asdfghjkl',
...'zxcvbnm',
'_', '-', '@', '.', '!', '?', '#', '$',
]
return (
<InputContext.Provider value={contextValue}>
{children}
{keyboardInput && (
<div className="controller-keyboard-backdrop" role="presentation">
<section className="controller-keyboard" aria-label="On-screen keyboard">
<div className="controller-keyboard-heading">
<div>
<p className="eyebrow">Controller Keyboard</p>
<strong>{keyboardInput.value || 'Enter text'}</strong>
</div>
<button onClick={closeKeyboard} type="button">Done</button>
</div>
<div className="controller-keyboard-grid">
{keyboardKeys.map((key) => (
<button key={key} onClick={() => typeKeyboardKey(key)} type="button">
{keyboardShift ? key.toUpperCase() : key}
</button>
))}
</div>
<div className="controller-keyboard-actions">
<button
className={keyboardShift ? 'active' : ''}
onClick={() => setKeyboardShift((value) => !value)}
type="button"
>
Shift
</button>
<button onClick={() => typeKeyboardKey('space')} type="button">Space</button>
<button onClick={() => typeKeyboardKey('backspace')} type="button">Backspace</button>
<button onClick={closeKeyboard} type="button">Done</button>
</div>
</section>
</div>
)}
</InputContext.Provider>
)
}
export function useInput() {
const context = useContext(InputContext)
if (!context) throw new Error('useInput must be used inside InputProvider')
return context
}
export function useGameAction(
handler: (action: InputAction, device: InputDevice) => void,
) {
const handlerRef = useRef(handler)
useEffect(() => {
handlerRef.current = handler
}, [handler])
useEffect(() => {
const listener = (event: Event) => {
const detail = (event as CustomEvent<{ action: InputAction; device: InputDevice }>).detail
handlerRef.current(detail.action, detail.device)
}
window.addEventListener(GAME_ACTION_EVENT, listener)
return () => window.removeEventListener(GAME_ACTION_EVENT, listener)
}, [])
}
export function dispatchExternalGameAction(
action: InputAction,
device: InputDevice,
) {
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
}))
}

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