Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6e2c61d0a | ||
|
|
f00ad8655b | ||
|
|
6560a049f2 | ||
|
|
89bc1b6ab8 | ||
|
|
5b8c32dc41 | ||
|
|
df6306fe7a | ||
|
|
f6d408f594 | ||
|
|
1797154726 | ||
|
|
bbcc67a54a |
@@ -1,9 +1,35 @@
|
||||
# Project Notes
|
||||
|
||||
## Target Hardware: AYN Thor
|
||||
|
||||
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
|
||||
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
|
||||
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
|
||||
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
|
||||
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
|
||||
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
||||
|
||||
## Input Requirements
|
||||
|
||||
- Every game screen, menu, dialog, overlay, and gameplay interaction must be fully navigable by controller at all times.
|
||||
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
|
||||
- Focus state must always be visible and predictable when controller navigation is active.
|
||||
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
|
||||
|
||||
## Architecture Requirements
|
||||
|
||||
- Keep systems modular and scoped by responsibility.
|
||||
- Prefer small reusable modules/components over large files with mixed concerns.
|
||||
- Keep shared game logic independent from platform-specific web or mobile wrappers whenever practical.
|
||||
- Apply game changes to both web version and mobile app version.
|
||||
|
||||
## Performance Requirements
|
||||
|
||||
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
|
||||
- Avoid unnecessary per-frame allocations, polling, timers, layout work, and re-renders.
|
||||
- Keep assets sized appropriately for Thor displays; do not ship oversized textures, images, audio, or data blobs without need.
|
||||
- Prefer event-driven updates and cached calculations for UI and game systems.
|
||||
- Profile or measure when changing hot paths, rendering, animation, input handling, asset loading, or persistent state.
|
||||
|
||||
## Build Requirements
|
||||
|
||||
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 81
|
||||
versionName "1.1.2"
|
||||
versionCode 90
|
||||
versionName "1.1.11"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.warren.iwanttoheal;
|
||||
|
||||
import android.view.Display;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
|
||||
final class AndroidDisplayPerformance {
|
||||
|
||||
private static final float TARGET_REFRESH_RATE = 60.0f;
|
||||
|
||||
private AndroidDisplayPerformance() {}
|
||||
|
||||
static void preferBatteryRefreshRate(Window window, Display display) {
|
||||
if (window == null || display == null) return;
|
||||
Display.Mode currentMode = display.getMode();
|
||||
Display.Mode selectedMode = closestBatteryMode(display, currentMode);
|
||||
if (selectedMode == null) return;
|
||||
|
||||
WindowManager.LayoutParams attributes = window.getAttributes();
|
||||
if (attributes.preferredDisplayModeId == selectedMode.getModeId()) return;
|
||||
attributes.preferredDisplayModeId = selectedMode.getModeId();
|
||||
window.setAttributes(attributes);
|
||||
}
|
||||
|
||||
private static Display.Mode closestBatteryMode(Display display, Display.Mode currentMode) {
|
||||
Display.Mode selectedMode = null;
|
||||
float selectedScore = Float.MAX_VALUE;
|
||||
|
||||
for (Display.Mode mode : display.getSupportedModes()) {
|
||||
if (
|
||||
currentMode != null
|
||||
&& (
|
||||
mode.getPhysicalWidth() != currentMode.getPhysicalWidth()
|
||||
|| mode.getPhysicalHeight() != currentMode.getPhysicalHeight()
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
float refreshRate = mode.getRefreshRate();
|
||||
float score = Math.abs(refreshRate - TARGET_REFRESH_RATE);
|
||||
if (refreshRate > TARGET_REFRESH_RATE + 0.5f) score += 1000.0f;
|
||||
if (score < selectedScore) {
|
||||
selectedMode = mode;
|
||||
selectedScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return selectedMode;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,45 @@
|
||||
package com.warren.iwanttoheal;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.SystemClock;
|
||||
import android.view.InputDevice;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import java.io.File;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
|
||||
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
|
||||
private static final long DPAD_THROTTLE_MS = 220;
|
||||
private long lastDpadDispatchAt = 0;
|
||||
private static final String PREFS_NAME = "com.warren.iwanttoheal.performance";
|
||||
private static final String CACHE_CLEARED_VERSION_KEY = "webview_cache_cleared_version";
|
||||
private static final float AXIS_DEAD_ZONE = 0.45f;
|
||||
private static final long DPAD_THROTTLE_MS = 55;
|
||||
private final long[] lastDpadDispatchAt = new long[16];
|
||||
private final Set<String> activeMotionTokens = new HashSet<>();
|
||||
private final Map<String, Long> lastMotionDispatchAt = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
clearWebViewServiceWorkers();
|
||||
boolean shouldClearWebViewCache = shouldClearWebViewCacheForCurrentVersion();
|
||||
if (shouldClearWebViewCache) clearWebViewServiceWorkers();
|
||||
super.onCreate(savedInstanceState);
|
||||
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), getWindowManager().getDefaultDisplay());
|
||||
if (bridge != null) {
|
||||
if (shouldClearWebViewCache) {
|
||||
bridge.getWebView().clearCache(true);
|
||||
markWebViewCacheClearedForCurrentVersion();
|
||||
}
|
||||
}
|
||||
loadIntentUrl();
|
||||
}
|
||||
@@ -35,7 +54,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) enableImmersiveMode();
|
||||
if (hasFocus) {
|
||||
enableImmersiveMode();
|
||||
AndroidDisplayPerformance.preferBatteryRefreshRate(
|
||||
getWindow(),
|
||||
getWindowManager().getDefaultDisplay()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -74,6 +99,34 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
deleteIfExists(new File(webViewData, "Service Worker"));
|
||||
}
|
||||
|
||||
private boolean shouldClearWebViewCacheForCurrentVersion() {
|
||||
return performancePreferences().getLong(CACHE_CLEARED_VERSION_KEY, -1L)
|
||||
!= currentVersionCode();
|
||||
}
|
||||
|
||||
private void markWebViewCacheClearedForCurrentVersion() {
|
||||
performancePreferences()
|
||||
.edit()
|
||||
.putLong(CACHE_CLEARED_VERSION_KEY, currentVersionCode())
|
||||
.apply();
|
||||
}
|
||||
|
||||
private SharedPreferences performancePreferences() {
|
||||
return getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
|
||||
}
|
||||
|
||||
private long currentVersionCode() {
|
||||
try {
|
||||
PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
return packageInfo.getLongVersionCode();
|
||||
}
|
||||
return packageInfo.versionCode;
|
||||
} catch (PackageManager.NameNotFoundException exception) {
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteIfExists(File file) {
|
||||
if (!file.exists()) return;
|
||||
if (file.isDirectory()) {
|
||||
@@ -94,7 +147,88 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
|
||||
if (event.getAction() == KeyEvent.ACTION_DOWN) {
|
||||
boolean repeat = event.getRepeatCount() > 0;
|
||||
if (isDpadToken(token) && shouldThrottleDpad()) return true;
|
||||
if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
|
||||
dispatchNativeControllerToken(token, repeat);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchGenericMotionEvent(MotionEvent event) {
|
||||
if (
|
||||
bridge == null
|
||||
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|
||||
|| !isControllerMotionEvent(event)
|
||||
) {
|
||||
return super.dispatchGenericMotionEvent(event);
|
||||
}
|
||||
|
||||
Set<String> currentTokens = new HashSet<>();
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_HAT_X),
|
||||
"Button14",
|
||||
"Button15"
|
||||
);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_HAT_Y),
|
||||
"Button12",
|
||||
"Button13"
|
||||
);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_X),
|
||||
"Axis0-",
|
||||
"Axis0+"
|
||||
);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_Y),
|
||||
"Axis1-",
|
||||
"Axis1+"
|
||||
);
|
||||
|
||||
boolean hadMotionTokens = !activeMotionTokens.isEmpty();
|
||||
long now = SystemClock.uptimeMillis();
|
||||
for (String token : currentTokens) {
|
||||
boolean repeat = activeMotionTokens.contains(token);
|
||||
long lastDispatchAt = lastMotionDispatchAt.containsKey(token)
|
||||
? lastMotionDispatchAt.get(token)
|
||||
: 0L;
|
||||
if (!repeat || now - lastDispatchAt >= DPAD_THROTTLE_MS) {
|
||||
dispatchNativeControllerToken(token, repeat);
|
||||
lastMotionDispatchAt.put(token, now);
|
||||
}
|
||||
}
|
||||
activeMotionTokens.clear();
|
||||
activeMotionTokens.addAll(currentTokens);
|
||||
|
||||
if (currentTokens.isEmpty() && !hadMotionTokens) {
|
||||
return super.dispatchGenericMotionEvent(event);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isControllerMotionEvent(MotionEvent event) {
|
||||
int source = event.getSource();
|
||||
return (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|
||||
|| (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD
|
||||
|| (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
|
||||
}
|
||||
|
||||
private void addAxisTokens(
|
||||
Set<String> tokens,
|
||||
float value,
|
||||
String negativeToken,
|
||||
String positiveToken
|
||||
) {
|
||||
if (value <= -AXIS_DEAD_ZONE) tokens.add(negativeToken);
|
||||
if (value >= AXIS_DEAD_ZONE) tokens.add(positiveToken);
|
||||
}
|
||||
|
||||
private void dispatchNativeControllerToken(String token, boolean repeat) {
|
||||
if (bridge == null || bridge.getWebView() == null) return;
|
||||
String script =
|
||||
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
|
||||
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
||||
@@ -105,13 +239,12 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
}
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean shouldThrottleDpad() {
|
||||
private boolean shouldThrottleDpad(String token) {
|
||||
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
|
||||
long now = SystemClock.uptimeMillis();
|
||||
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true;
|
||||
lastDpadDispatchAt = now;
|
||||
if (now - lastDpadDispatchAt[buttonIndex] < DPAD_THROTTLE_MS) return true;
|
||||
lastDpadDispatchAt[buttonIndex] = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -125,10 +258,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
private String controllerToken(int keyCode) {
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_BUTTON_A:
|
||||
case KeyEvent.KEYCODE_DPAD_CENTER:
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
case KeyEvent.KEYCODE_NUMPAD_ENTER:
|
||||
return "Button0";
|
||||
case KeyEvent.KEYCODE_BUTTON_B:
|
||||
case KeyEvent.KEYCODE_BACK:
|
||||
case KeyEvent.KEYCODE_ESCAPE:
|
||||
return "Button1";
|
||||
case KeyEvent.KEYCODE_BUTTON_X:
|
||||
return "Button2";
|
||||
|
||||
@@ -259,9 +259,11 @@ public class DualScreenPlugin extends Plugin {
|
||||
private final class TopDisplayPresentation extends Presentation {
|
||||
|
||||
private final String initialUrl;
|
||||
private final Display display;
|
||||
|
||||
TopDisplayPresentation(Context context, Display display, String initialUrl) {
|
||||
super(context, display);
|
||||
this.display = display;
|
||||
this.initialUrl = initialUrl;
|
||||
}
|
||||
|
||||
@@ -277,6 +279,7 @@ public class DualScreenPlugin extends Plugin {
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
);
|
||||
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), display);
|
||||
|
||||
FrameLayout container = new FrameLayout(getContext());
|
||||
container.setBackgroundColor(Color.BLACK);
|
||||
|
||||
@@ -254,6 +254,20 @@ CREATE TABLE IF NOT EXISTS character_inventory (
|
||||
PRIMARY KEY (character_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS character_boss_stats (
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||
encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE,
|
||||
kills INTEGER NOT NULL DEFAULT 0 CHECK (kills >= 0),
|
||||
pet_quantity INTEGER NOT NULL DEFAULT 0 CHECK (pet_quantity >= 0),
|
||||
PRIMARY KEY (character_id, encounter_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS character_pvp_stats (
|
||||
character_id INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE,
|
||||
matches_played INTEGER NOT NULL DEFAULT 0 CHECK (matches_played >= 0),
|
||||
matches_won INTEGER NOT NULL DEFAULT 0 CHECK (matches_won >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS action_characters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
|
||||
Generated
+64
@@ -16,6 +16,7 @@
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.4.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -801,6 +802,22 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
@@ -2972,6 +2989,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/plist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"android:open": "cap open android",
|
||||
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
|
||||
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
|
||||
"release:gui": "python3 scripts/release_game.py",
|
||||
"release:cli": "python3 scripts/release_game_cli.py",
|
||||
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
|
||||
"db:backup": "node scripts/backup-db.mjs",
|
||||
"db:init": "node scripts/init-db.mjs",
|
||||
@@ -32,6 +34,7 @@
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.4.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
Binary file not shown.
Executable
+502
@@ -0,0 +1,502 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
except ImportError:
|
||||
tk = None
|
||||
messagebox = None
|
||||
ttk = None
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
|
||||
DEFAULT_JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
DEFAULT_API_BASE_URL = "https://iwanttoheal.phenomrom.com"
|
||||
DEFAULT_GITEA_URL = "https://git.whoagland.com"
|
||||
DEFAULT_GITEA_OWNER = "phenom"
|
||||
DEFAULT_GITEA_REPO = "i-want-to-heal"
|
||||
DEFAULT_BRANCH = "main"
|
||||
DEFAULT_TRUENAS_PATH = "/mnt/usbssds/apps/iwanttoheal/app"
|
||||
DEFAULT_DB_PATH = "/mnt/usbssds/apps/iwanttoheal/data/game.db"
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseConfig:
|
||||
version: str
|
||||
token: str
|
||||
api_base_url: str
|
||||
java_home: str
|
||||
gitea_url: str
|
||||
gitea_owner: str
|
||||
gitea_repo: str
|
||||
branch: str
|
||||
truenas_path: str
|
||||
truenas_db_path: str
|
||||
restart_command: str
|
||||
run_build: bool
|
||||
run_commit_push: bool
|
||||
run_release: bool
|
||||
run_truenas_pull: bool
|
||||
run_truenas_backup: bool
|
||||
run_restart: bool
|
||||
|
||||
|
||||
def read_current_version() -> str:
|
||||
text = GRADLE_FILE.read_text()
|
||||
match = re.search(r'versionName\s+"([^"]+)"', text)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def run_command(
|
||||
args: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
env: dict[str, str] | None,
|
||||
log: Callable[[str], None],
|
||||
) -> None:
|
||||
log(f"$ {' '.join(args)}")
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
log(line.rstrip("\n"))
|
||||
code = proc.wait()
|
||||
if code != 0:
|
||||
raise ReleaseError(f"Command failed ({code}): {' '.join(args)}")
|
||||
|
||||
|
||||
def update_gradle_version(version: str, log: Callable[[str], None]) -> int:
|
||||
text = GRADLE_FILE.read_text()
|
||||
code_match = re.search(r"versionCode\s+(\d+)", text)
|
||||
if not code_match:
|
||||
raise ReleaseError(f"Could not find versionCode in {GRADLE_FILE}")
|
||||
|
||||
next_code = int(code_match.group(1)) + 1
|
||||
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
|
||||
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
|
||||
GRADLE_FILE.write_text(text)
|
||||
log(f"Android version set: versionName {version}, versionCode {next_code}")
|
||||
return next_code
|
||||
|
||||
|
||||
def build_apk(config: ReleaseConfig, log: Callable[[str], None]) -> Path:
|
||||
update_gradle_version(config.version, log)
|
||||
env = os.environ.copy()
|
||||
env["JAVA_HOME"] = config.java_home
|
||||
env["PATH"] = f"{config.java_home}/bin:{env.get('PATH', '')}"
|
||||
env["VITE_API_BASE_URL"] = config.api_base_url
|
||||
|
||||
run_command(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env, log=log)
|
||||
run_command(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env, log=log)
|
||||
|
||||
apk_source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
|
||||
if not apk_source.exists():
|
||||
raise ReleaseError(f"APK not found: {apk_source}")
|
||||
apk_target = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
|
||||
apk_target.write_bytes(apk_source.read_bytes())
|
||||
log(f"APK copied: {apk_target.name} ({apk_target.stat().st_size:,} bytes)")
|
||||
return apk_target
|
||||
|
||||
|
||||
def commit_and_push(config: ReleaseConfig, log: Callable[[str], None]) -> None:
|
||||
message = f"Android build v{config.version}"
|
||||
run_command(["git", "add", "."], cwd=REPO_ROOT, env=None, log=log)
|
||||
run_command(["git", "commit", "-m", message], cwd=REPO_ROOT, env=None, log=log)
|
||||
run_command(["git", "push", "origin", config.branch], cwd=REPO_ROOT, env=None, log=log)
|
||||
|
||||
|
||||
def gitea_request(
|
||||
config: ReleaseConfig,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
body: bytes | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> tuple[int, bytes]:
|
||||
url = config.gitea_url.rstrip("/") + path
|
||||
headers = {"Authorization": f"token {config.token}"}
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
data = exc.read()
|
||||
return exc.code, data
|
||||
|
||||
|
||||
def parse_json_response(status: int, data: bytes, action: str) -> dict:
|
||||
try:
|
||||
parsed = json.loads(data.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ReleaseError(f"{action} failed ({status}): {data.decode('utf-8', 'replace')}") from exc
|
||||
if status >= 400:
|
||||
raise ReleaseError(f"{action} failed ({status}): {parsed}")
|
||||
if not isinstance(parsed, dict):
|
||||
raise ReleaseError(f"{action} returned unexpected JSON: {parsed}")
|
||||
return parsed
|
||||
|
||||
|
||||
def find_or_create_release(config: ReleaseConfig, log: Callable[[str], None]) -> int:
|
||||
repo_path = f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
|
||||
tag = f"v{config.version}"
|
||||
tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
|
||||
status, data = gitea_request(config, "GET", tag_path)
|
||||
if status == 200:
|
||||
release = parse_json_response(status, data, "Find release")
|
||||
release_id = release.get("id")
|
||||
if not release_id:
|
||||
raise ReleaseError(f"Existing release missing id: {release}")
|
||||
log(f"Gitea release exists: {tag} (id {release_id})")
|
||||
return int(release_id)
|
||||
if status != 404:
|
||||
parse_json_response(status, data, "Find release")
|
||||
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"target_commitish": config.branch,
|
||||
"name": tag,
|
||||
"body": f"I Want to Heal Android build v{config.version}",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}
|
||||
status, data = gitea_request(
|
||||
config,
|
||||
"POST",
|
||||
repo_path + "/releases",
|
||||
body=json.dumps(payload).encode("utf-8"),
|
||||
content_type="application/json",
|
||||
)
|
||||
release = parse_json_response(status, data, "Create release")
|
||||
release_id = release.get("id")
|
||||
if not release_id:
|
||||
raise ReleaseError(f"Created release missing id: {release}")
|
||||
log(f"Gitea release created: {tag} (id {release_id})")
|
||||
return int(release_id)
|
||||
|
||||
|
||||
def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> None:
|
||||
apk_path = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
|
||||
if not apk_path.exists():
|
||||
raise ReleaseError(f"APK not found for release upload: {apk_path}")
|
||||
release_id = find_or_create_release(config, log)
|
||||
|
||||
boundary = f"----iwanttoheal{int(time.time() * 1000)}"
|
||||
header = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
|
||||
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
body = header + apk_path.read_bytes() + footer
|
||||
asset_name = urllib.parse.quote(apk_path.name)
|
||||
path = (
|
||||
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
|
||||
f"/releases/{release_id}/assets?name={asset_name}"
|
||||
)
|
||||
status, data = gitea_request(
|
||||
config,
|
||||
"POST",
|
||||
path,
|
||||
body=body,
|
||||
content_type=f"multipart/form-data; boundary={boundary}",
|
||||
)
|
||||
if status == 409:
|
||||
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
|
||||
parse_json_response(status, data, "Upload release asset")
|
||||
log(f"Gitea asset uploaded: {apk_path.name}")
|
||||
|
||||
|
||||
def update_truenas(config: ReleaseConfig, log: Callable[[str], None]) -> None:
|
||||
app_path = Path(config.truenas_path)
|
||||
if not app_path.exists():
|
||||
raise ReleaseError(f"TrueNAS path not found: {app_path}")
|
||||
run_command(["git", "pull"], cwd=app_path, env=None, log=log)
|
||||
|
||||
if config.run_truenas_backup:
|
||||
db_path = Path(config.truenas_db_path)
|
||||
if not db_path.exists():
|
||||
raise ReleaseError(f"TrueNAS database not found: {db_path}")
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = db_path.with_name(f"game-before-update-{stamp}.db")
|
||||
backup_path.write_bytes(db_path.read_bytes())
|
||||
log(f"TrueNAS database backup copied: {backup_path}")
|
||||
|
||||
if config.run_restart:
|
||||
if not config.restart_command.strip():
|
||||
raise ReleaseError("Restart command is empty")
|
||||
run_command(["sh", "-lc", config.restart_command], cwd=app_path, env=None, log=log)
|
||||
|
||||
|
||||
def validate_config(config: ReleaseConfig) -> None:
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", config.version):
|
||||
raise ReleaseError("Version must look like 1.1.2")
|
||||
if config.run_release and not config.token:
|
||||
raise ReleaseError("Gitea token required for release step")
|
||||
if config.run_restart and not config.run_truenas_pull:
|
||||
raise ReleaseError("Restart needs TrueNAS update step enabled")
|
||||
|
||||
|
||||
def prompt_text(label: str, default: str = "", *, secret: bool = False) -> str:
|
||||
prompt = f"{label}"
|
||||
if default:
|
||||
prompt += f" [{default}]"
|
||||
prompt += ": "
|
||||
try:
|
||||
value = getpass(prompt) if secret else input(prompt)
|
||||
except EOFError:
|
||||
value = ""
|
||||
return value.strip() or default
|
||||
|
||||
|
||||
def prompt_bool(label: str, default: bool) -> bool:
|
||||
suffix = "Y/n" if default else "y/N"
|
||||
try:
|
||||
value = input(f"{label} [{suffix}]: ").strip().lower()
|
||||
except EOFError:
|
||||
value = ""
|
||||
if not value:
|
||||
return default
|
||||
return value in {"y", "yes", "true", "1"}
|
||||
|
||||
|
||||
def run_release_steps(config: ReleaseConfig, log: Callable[[str], None]) -> None:
|
||||
if config.run_build:
|
||||
build_apk(config, log)
|
||||
if config.run_commit_push:
|
||||
commit_and_push(config, log)
|
||||
if config.run_release:
|
||||
upload_release_asset(config, log)
|
||||
if config.run_truenas_pull:
|
||||
update_truenas(config, log)
|
||||
|
||||
|
||||
def cli_main() -> int:
|
||||
print("Tkinter not available. CLI mode.")
|
||||
current_version = read_current_version()
|
||||
config = ReleaseConfig(
|
||||
version=prompt_text("Version", current_version),
|
||||
token=prompt_text("Gitea token", secret=True),
|
||||
api_base_url=prompt_text("API base URL", DEFAULT_API_BASE_URL),
|
||||
java_home=prompt_text("JAVA_HOME", DEFAULT_JAVA_HOME),
|
||||
gitea_url=prompt_text("Gitea URL", DEFAULT_GITEA_URL),
|
||||
gitea_owner=prompt_text("Gitea owner", DEFAULT_GITEA_OWNER),
|
||||
gitea_repo=prompt_text("Gitea repo", DEFAULT_GITEA_REPO),
|
||||
branch=prompt_text("Git branch", DEFAULT_BRANCH),
|
||||
truenas_path=prompt_text("TrueNAS app path", DEFAULT_TRUENAS_PATH),
|
||||
truenas_db_path=prompt_text("TrueNAS DB path", DEFAULT_DB_PATH),
|
||||
restart_command=prompt_text("Restart command"),
|
||||
run_build=prompt_bool("Build APK", True),
|
||||
run_commit_push=prompt_bool("Commit + push", True),
|
||||
run_release=prompt_bool("Create Gitea release", True),
|
||||
run_truenas_pull=prompt_bool("TrueNAS pull", True),
|
||||
run_truenas_backup=prompt_bool("Backup DB", True),
|
||||
run_restart=prompt_bool("Restart", False),
|
||||
)
|
||||
validate_config(config)
|
||||
run_release_steps(config, print)
|
||||
print("Release steps complete")
|
||||
return 0
|
||||
|
||||
|
||||
if tk is not None and ttk is not None and messagebox is not None:
|
||||
|
||||
class ReleaseApp(ttk.Frame):
|
||||
def __init__(self, root: tk.Tk) -> None:
|
||||
super().__init__(root, padding=12)
|
||||
self.root = root
|
||||
self.log_queue: queue.Queue[tuple[str, str]] = queue.Queue()
|
||||
self.worker: threading.Thread | None = None
|
||||
self.vars: dict[str, tk.Variable] = {}
|
||||
self.grid(sticky="nsew")
|
||||
root.title("I Want to Heal Release Tool")
|
||||
root.geometry("920x720")
|
||||
root.columnconfigure(0, weight=1)
|
||||
root.rowconfigure(0, weight=1)
|
||||
self.columnconfigure(1, weight=1)
|
||||
self.rowconfigure(13, weight=1)
|
||||
self._build_form()
|
||||
self.after(100, self._drain_log_queue)
|
||||
|
||||
def _var(self, name: str, value: object = "") -> tk.Variable:
|
||||
if isinstance(value, bool):
|
||||
var: tk.Variable = tk.BooleanVar(value=value)
|
||||
else:
|
||||
var = tk.StringVar(value=str(value))
|
||||
self.vars[name] = var
|
||||
return var
|
||||
|
||||
def _build_form(self) -> None:
|
||||
current_version = read_current_version()
|
||||
fields = [
|
||||
("Version", "version", current_version),
|
||||
("Gitea token", "token", ""),
|
||||
("API base URL", "api_base_url", DEFAULT_API_BASE_URL),
|
||||
("JAVA_HOME", "java_home", DEFAULT_JAVA_HOME),
|
||||
("Gitea URL", "gitea_url", DEFAULT_GITEA_URL),
|
||||
("Gitea owner", "gitea_owner", DEFAULT_GITEA_OWNER),
|
||||
("Gitea repo", "gitea_repo", DEFAULT_GITEA_REPO),
|
||||
("Git branch", "branch", DEFAULT_BRANCH),
|
||||
("TrueNAS app path", "truenas_path", DEFAULT_TRUENAS_PATH),
|
||||
("TrueNAS DB path", "truenas_db_path", DEFAULT_DB_PATH),
|
||||
("Restart command", "restart_command", ""),
|
||||
]
|
||||
for row, (label, name, value) in enumerate(fields):
|
||||
ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=3)
|
||||
entry = ttk.Entry(self, textvariable=self._var(name, value), show="*" if name == "token" else "")
|
||||
entry.grid(row=row, column=1, sticky="ew", pady=3)
|
||||
|
||||
checks = ttk.Frame(self)
|
||||
checks.grid(row=11, column=0, columnspan=2, sticky="ew", pady=(10, 4))
|
||||
check_data = [
|
||||
("Build APK", "run_build", True),
|
||||
("Commit + push", "run_commit_push", True),
|
||||
("Create Gitea release", "run_release", True),
|
||||
("TrueNAS pull", "run_truenas_pull", True),
|
||||
("Backup DB", "run_truenas_backup", True),
|
||||
("Restart", "run_restart", False),
|
||||
]
|
||||
for index, (text, name, value) in enumerate(check_data):
|
||||
ttk.Checkbutton(checks, text=text, variable=self._var(name, value)).grid(
|
||||
row=0, column=index, padx=(0, 14), sticky="w"
|
||||
)
|
||||
|
||||
buttons = ttk.Frame(self)
|
||||
buttons.grid(row=12, column=0, columnspan=2, sticky="ew", pady=(6, 8))
|
||||
self.run_button = ttk.Button(buttons, text="Run Selected Steps", command=self.run_release)
|
||||
self.run_button.pack(side="left")
|
||||
ttk.Button(buttons, text="Start Admin Server", command=self.start_admin).pack(side="left", padx=8)
|
||||
|
||||
self.log_text = tk.Text(self, height=18, wrap="word")
|
||||
self.log_text.grid(row=13, column=0, columnspan=2, sticky="nsew")
|
||||
scroll = ttk.Scrollbar(self, command=self.log_text.yview)
|
||||
scroll.grid(row=13, column=2, sticky="ns")
|
||||
self.log_text.configure(yscrollcommand=scroll.set)
|
||||
|
||||
def config_from_form(self) -> ReleaseConfig:
|
||||
get = lambda name: str(self.vars[name].get()).strip()
|
||||
get_bool = lambda name: bool(self.vars[name].get())
|
||||
return ReleaseConfig(
|
||||
version=get("version"),
|
||||
token=get("token"),
|
||||
api_base_url=get("api_base_url"),
|
||||
java_home=get("java_home"),
|
||||
gitea_url=get("gitea_url"),
|
||||
gitea_owner=get("gitea_owner"),
|
||||
gitea_repo=get("gitea_repo"),
|
||||
branch=get("branch"),
|
||||
truenas_path=get("truenas_path"),
|
||||
truenas_db_path=get("truenas_db_path"),
|
||||
restart_command=get("restart_command"),
|
||||
run_build=get_bool("run_build"),
|
||||
run_commit_push=get_bool("run_commit_push"),
|
||||
run_release=get_bool("run_release"),
|
||||
run_truenas_pull=get_bool("run_truenas_pull"),
|
||||
run_truenas_backup=get_bool("run_truenas_backup"),
|
||||
run_restart=get_bool("run_restart"),
|
||||
)
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
self.log_queue.put(("log", message))
|
||||
|
||||
def _drain_log_queue(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
kind, message = self.log_queue.get_nowait()
|
||||
if kind == "done":
|
||||
self.run_button.configure(state="normal")
|
||||
if message:
|
||||
messagebox.showinfo("Release tool", message)
|
||||
elif kind == "error":
|
||||
self.run_button.configure(state="normal")
|
||||
messagebox.showerror("Release failed", message)
|
||||
else:
|
||||
self.log_text.insert("end", message + "\n")
|
||||
self.log_text.see("end")
|
||||
except queue.Empty:
|
||||
pass
|
||||
self.after(100, self._drain_log_queue)
|
||||
|
||||
def run_release(self) -> None:
|
||||
if self.worker and self.worker.is_alive():
|
||||
return
|
||||
try:
|
||||
config = self.config_from_form()
|
||||
validate_config(config)
|
||||
except ReleaseError as exc:
|
||||
messagebox.showerror("Invalid settings", str(exc))
|
||||
return
|
||||
self.run_button.configure(state="disabled")
|
||||
self.log_text.delete("1.0", "end")
|
||||
self.worker = threading.Thread(target=self._run_release_worker, args=(config,), daemon=True)
|
||||
self.worker.start()
|
||||
|
||||
def _run_release_worker(self, config: ReleaseConfig) -> None:
|
||||
try:
|
||||
run_release_steps(config, self.log)
|
||||
self.log_queue.put(("done", "Release steps complete"))
|
||||
except Exception as exc:
|
||||
self.log_queue.put(("error", str(exc)))
|
||||
|
||||
def start_admin(self) -> None:
|
||||
if self.worker and self.worker.is_alive():
|
||||
messagebox.showerror("Busy", "Release steps are still running")
|
||||
return
|
||||
self.log_text.delete("1.0", "end")
|
||||
self.run_button.configure(state="disabled")
|
||||
self.worker = threading.Thread(target=self._start_admin_worker, daemon=True)
|
||||
self.worker.start()
|
||||
|
||||
def _start_admin_worker(self) -> None:
|
||||
try:
|
||||
self.log("Admin URL: http://127.0.0.1:4174")
|
||||
run_command(["npm", "run", "admin:start"], cwd=REPO_ROOT, env=None, log=self.log)
|
||||
self.log_queue.put(("done", "Admin server stopped"))
|
||||
except Exception as exc:
|
||||
self.log_queue.put(("error", str(exc)))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not GRADLE_FILE.exists():
|
||||
print(f"Missing Gradle file: {GRADLE_FILE}", file=sys.stderr)
|
||||
return 1
|
||||
if tk is None:
|
||||
return cli_main()
|
||||
root = tk.Tk()
|
||||
ReleaseApp(root)
|
||||
root.mainloop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+304
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
|
||||
JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
API_BASE_URL = "https://iwanttoheal.phenomrom.com"
|
||||
GITEA_URL = "https://git.whoagland.com"
|
||||
GITEA_OWNER = "phenom"
|
||||
GITEA_REPO = "i-want-to-heal"
|
||||
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
||||
BRANCH = "main"
|
||||
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app")
|
||||
PLAYER_DATA_TABLES = [
|
||||
"encounter_loot_roll_items",
|
||||
"encounter_loot_rolls",
|
||||
"dungeon_runs",
|
||||
"character_pvp_stats",
|
||||
"character_boss_stats",
|
||||
"character_inventory",
|
||||
"character_talents",
|
||||
"character_ability_slots",
|
||||
"action_encounter_loot_rolls",
|
||||
"action_dungeon_runs",
|
||||
"action_character_inventory",
|
||||
"action_gear_items",
|
||||
"action_characters",
|
||||
"sessions",
|
||||
"characters",
|
||||
"accounts",
|
||||
]
|
||||
|
||||
|
||||
def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
|
||||
print(f"$ {' '.join(args)}", flush=True)
|
||||
subprocess.run(args, cwd=cwd, env=env, check=True)
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Build and publish an Android release.")
|
||||
parser.add_argument(
|
||||
"--reset-player-data",
|
||||
action="store_true",
|
||||
help="After release, back up the database and delete all accounts, characters, sessions, runs, loot, inventory, and PvP stats.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reset-db",
|
||||
type=Path,
|
||||
help="SQLite game.db path to reset. Defaults to mounted TrueNAS data/game.db when present, otherwise local data/game.db.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--yes-reset",
|
||||
action="store_true",
|
||||
help="Skip the RESET confirmation when used with --reset-player-data.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.yes_reset and not args.reset_player_data:
|
||||
parser.error("--yes-reset requires --reset-player-data")
|
||||
return args
|
||||
|
||||
|
||||
def prompt_version() -> str:
|
||||
current = GRADLE_FILE.read_text()
|
||||
match = re.search(r'versionName\s+"([^"]+)"', current)
|
||||
current_version = match.group(1) if match else ""
|
||||
suffix = f" [{current_version}]" if current_version else ""
|
||||
version = input(f"Version{suffix}: ").strip() or current_version
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
|
||||
raise SystemExit("Version must look like 1.1.2")
|
||||
return version
|
||||
|
||||
|
||||
def update_gradle_version(version: str) -> None:
|
||||
text = GRADLE_FILE.read_text()
|
||||
code_match = re.search(r"versionCode\s+(\d+)", text)
|
||||
if not code_match:
|
||||
raise SystemExit(f"Could not find versionCode in {GRADLE_FILE}")
|
||||
|
||||
next_code = int(code_match.group(1)) + 1
|
||||
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
|
||||
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
|
||||
GRADLE_FILE.write_text(text)
|
||||
print(f"Android version: {version}, versionCode: {next_code}")
|
||||
|
||||
|
||||
def build_apk(version: str) -> Path:
|
||||
update_gradle_version(version)
|
||||
env = os.environ.copy()
|
||||
env["JAVA_HOME"] = JAVA_HOME
|
||||
env["PATH"] = f"{JAVA_HOME}/bin:{env.get('PATH', '')}"
|
||||
env["VITE_API_BASE_URL"] = API_BASE_URL
|
||||
|
||||
run(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env)
|
||||
run(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env)
|
||||
|
||||
source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
|
||||
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
|
||||
shutil.copy2(source, apk)
|
||||
print(f"APK: {apk}")
|
||||
return apk
|
||||
|
||||
|
||||
def commit_and_push(version: str) -> None:
|
||||
run(["git", "add", "."], cwd=REPO_ROOT)
|
||||
run(["git", "commit", "-m", f"Android build v{version}"], cwd=REPO_ROOT)
|
||||
run(["git", "push", "origin", BRANCH], cwd=REPO_ROOT)
|
||||
|
||||
|
||||
def request_json(method: str, path: str, token: str, data: dict | None = None) -> dict:
|
||||
body = json.dumps(data).encode("utf-8") if data is not None else None
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")
|
||||
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
|
||||
|
||||
|
||||
def upload_asset(path: str, token: str, apk: Path) -> dict:
|
||||
boundary = "----iwanttoheal-release-boundary"
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n'
|
||||
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
body += apk.read_bytes()
|
||||
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
}
|
||||
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")
|
||||
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc
|
||||
|
||||
|
||||
def create_gitea_release(version: str, apk: Path) -> None:
|
||||
token = GITEA_TOKEN.strip()
|
||||
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
|
||||
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
|
||||
|
||||
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
|
||||
release = request_json(
|
||||
"POST",
|
||||
repo_path + "/releases",
|
||||
token,
|
||||
{
|
||||
"tag_name": f"v{version}",
|
||||
"target_commitish": BRANCH,
|
||||
"name": f"v{version}",
|
||||
"body": f"I Want to Heal Android build v{version}",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
},
|
||||
)
|
||||
release_id = release.get("id")
|
||||
if not release_id:
|
||||
raise SystemExit(f"Gitea release missing id: {release}")
|
||||
|
||||
asset_name = urllib.parse.quote(apk.name)
|
||||
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
|
||||
print(f"Gitea release uploaded: v{version}")
|
||||
|
||||
|
||||
def update_truenas() -> None:
|
||||
if TRUENAS_PATH.exists():
|
||||
run(["git", "pull"], cwd=TRUENAS_PATH)
|
||||
print("Restart app in TrueNAS UI.")
|
||||
return
|
||||
|
||||
print("TrueNAS path not mounted here. Run on TrueNAS:")
|
||||
print(f"cd {TRUENAS_PATH}")
|
||||
print("git pull")
|
||||
print("Then restart app in TrueNAS UI.")
|
||||
|
||||
|
||||
def default_reset_database_path() -> Path:
|
||||
truenas_database = TRUENAS_PATH / "data" / "game.db"
|
||||
if truenas_database.exists():
|
||||
return truenas_database
|
||||
return REPO_ROOT / "data" / "game.db"
|
||||
|
||||
|
||||
def table_exists(database: sqlite3.Connection, table: str) -> bool:
|
||||
return database.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
(table,),
|
||||
).fetchone() is not None
|
||||
|
||||
|
||||
def count_rows(database: sqlite3.Connection, table: str) -> int:
|
||||
if not table_exists(database, table):
|
||||
return 0
|
||||
return int(database.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0])
|
||||
|
||||
|
||||
def backup_database(database_path: Path) -> Path:
|
||||
backup_dir = database_path.parent / "backups"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = backup_dir / f"{database_path.stem}-pre-player-reset-{timestamp}{database_path.suffix}"
|
||||
|
||||
source = sqlite3.connect(database_path)
|
||||
try:
|
||||
destination = sqlite3.connect(backup_path)
|
||||
try:
|
||||
source.backup(destination)
|
||||
finally:
|
||||
destination.close()
|
||||
finally:
|
||||
source.close()
|
||||
|
||||
return backup_path
|
||||
|
||||
|
||||
def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
|
||||
database_path = database_path.expanduser().resolve()
|
||||
if not database_path.exists():
|
||||
raise SystemExit(f"Database not found: {database_path}")
|
||||
|
||||
if not assume_yes:
|
||||
print(f"Reset target: {database_path}")
|
||||
confirmation = input("Type RESET to delete all accounts/characters/player data: ").strip()
|
||||
if confirmation != "RESET":
|
||||
raise SystemExit("Player data reset cancelled.")
|
||||
|
||||
backup_path = backup_database(database_path)
|
||||
database = sqlite3.connect(database_path)
|
||||
try:
|
||||
before = {
|
||||
"accounts": count_rows(database, "accounts"),
|
||||
"characters": count_rows(database, "characters"),
|
||||
"action_characters": count_rows(database, "action_characters"),
|
||||
"dungeon_runs": count_rows(database, "dungeon_runs"),
|
||||
"encounter_loot_rolls": count_rows(database, "encounter_loot_rolls"),
|
||||
}
|
||||
database.execute("PRAGMA foreign_keys = ON")
|
||||
database.execute("BEGIN")
|
||||
try:
|
||||
for table in PLAYER_DATA_TABLES:
|
||||
if table_exists(database, table):
|
||||
database.execute(f'DELETE FROM "{table}"')
|
||||
database.execute("COMMIT")
|
||||
except Exception:
|
||||
database.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
after_accounts = count_rows(database, "accounts")
|
||||
after_characters = count_rows(database, "characters")
|
||||
if after_accounts or after_characters:
|
||||
raise SystemExit(
|
||||
f"Player data reset incomplete: {after_accounts} accounts, {after_characters} characters remain."
|
||||
)
|
||||
|
||||
print(f"Database backup: {backup_path}")
|
||||
print(
|
||||
"Player data reset: "
|
||||
f"{before['accounts']} accounts, "
|
||||
f"{before['characters']} characters, "
|
||||
f"{before['action_characters']} action characters, "
|
||||
f"{before['dungeon_runs']} dungeon runs, "
|
||||
f"{before['encounter_loot_rolls']} loot rolls removed."
|
||||
)
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||
os.chdir(REPO_ROOT)
|
||||
version = prompt_version()
|
||||
apk = build_apk(version)
|
||||
commit_and_push(version)
|
||||
create_gitea_release(version, apk)
|
||||
update_truenas()
|
||||
if args.reset_player_data:
|
||||
reset_player_data(args.reset_db or default_reset_database_path(), assume_yes=args.yes_reset)
|
||||
print("Done.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -55,6 +55,12 @@ export function normalizeCatalogProfile(profile) {
|
||||
})),
|
||||
craftingRecipes: profile.craftingRecipes.map(normalizeRecipe),
|
||||
dungeons: profile.dungeons.map(normalizeDungeon),
|
||||
hunterStats: {
|
||||
bossKills: {},
|
||||
bossPets: {},
|
||||
pvpMatchesPlayed: 0,
|
||||
pvpMatchesWon: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+77
-2
@@ -815,6 +815,21 @@ export function getProfile(database, characterId, accountId) {
|
||||
AND completed_parts >= 1
|
||||
GROUP BY dungeon_id
|
||||
`).all(characterId).map((row) => [row.dungeonId, row.count]))
|
||||
const bossStatRows = database.prepare(`
|
||||
SELECT
|
||||
encounter_id AS encounterId,
|
||||
kills,
|
||||
pet_quantity AS petQuantity
|
||||
FROM character_boss_stats
|
||||
WHERE character_id = ?
|
||||
`).all(characterId)
|
||||
const pvpStats = database.prepare(`
|
||||
SELECT
|
||||
matches_played AS matchesPlayed,
|
||||
matches_won AS matchesWon
|
||||
FROM character_pvp_stats
|
||||
WHERE character_id = ?
|
||||
`).get(characterId)
|
||||
|
||||
const settings = Object.fromEntries(
|
||||
database.prepare('SELECT key, value FROM game_settings').all()
|
||||
@@ -930,6 +945,12 @@ export function getProfile(database, characterId, accountId) {
|
||||
.map((run) => ({ ...run, rank: Number(run.rank) })),
|
||||
},
|
||||
})),
|
||||
hunterStats: {
|
||||
bossKills: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.kills])),
|
||||
bossPets: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.petQuantity])),
|
||||
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
|
||||
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,8 +1046,18 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
||||
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
|
||||
?? characters[0]?.name
|
||||
?? 'Mira'
|
||||
const bossStats = database.prepare(`
|
||||
SELECT encounter_id AS encounterId, kills, pet_quantity AS petQuantity
|
||||
FROM character_boss_stats
|
||||
WHERE character_id = ?
|
||||
`).all(activeCharacterId)
|
||||
const pvpStats = database.prepare(`
|
||||
SELECT matches_played AS matchesPlayed, matches_won AS matchesWon
|
||||
FROM character_pvp_stats
|
||||
WHERE character_id = ?
|
||||
`).get(activeCharacterId)
|
||||
return {
|
||||
version: 3,
|
||||
version: 4,
|
||||
characterName,
|
||||
activeClassId,
|
||||
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
||||
@@ -1038,6 +1069,10 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
||||
]),
|
||||
),
|
||||
lootRolls: {},
|
||||
bossKills: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.kills])),
|
||||
bossPets: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.petQuantity])),
|
||||
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
|
||||
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,7 +1087,7 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
||||
if (
|
||||
!save
|
||||
|| typeof save !== 'object'
|
||||
|| Number(save.version) !== 3
|
||||
|| (Number(save.version) !== 4 && Number(save.version) !== 3)
|
||||
|| typeof save.characterName !== 'string'
|
||||
|| !save.characters
|
||||
|| typeof save.characters !== 'object'
|
||||
@@ -1149,6 +1184,10 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
||||
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`)
|
||||
const insertBossStats = database.prepare(`
|
||||
INSERT INTO character_boss_stats (character_id, encounter_id, kills, pet_quantity)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
for (const classId of classIds) {
|
||||
const local = save.characters[classId]
|
||||
@@ -1213,6 +1252,14 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
||||
DELETE FROM character_inventory
|
||||
WHERE character_id = ?
|
||||
`).run(characterId)
|
||||
database.prepare(`
|
||||
DELETE FROM character_boss_stats
|
||||
WHERE character_id = ?
|
||||
`).run(characterId)
|
||||
database.prepare(`
|
||||
DELETE FROM character_pvp_stats
|
||||
WHERE character_id = ?
|
||||
`).run(characterId)
|
||||
const inventoryByItemId = new Map()
|
||||
const equippedSlots = new Set()
|
||||
for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
|
||||
@@ -1235,6 +1282,34 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
||||
for (const [itemId, itemState] of inventoryByItemId) {
|
||||
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
|
||||
}
|
||||
if (classId === Number(save.activeClassId)) {
|
||||
const bossKills = save.bossKills && typeof save.bossKills === 'object' ? save.bossKills : {}
|
||||
const bossPets = save.bossPets && typeof save.bossPets === 'object' ? save.bossPets : {}
|
||||
for (const [encounterId, rawKills] of Object.entries(bossKills)) {
|
||||
const id = Number(encounterId)
|
||||
if (!Number.isInteger(id)) continue
|
||||
insertBossStats.run(
|
||||
characterId,
|
||||
id,
|
||||
clampInteger(rawKills, 0, 0, 1000000),
|
||||
clampInteger(bossPets[encounterId], 0, 0, 1000000),
|
||||
)
|
||||
}
|
||||
for (const [encounterId, rawPets] of Object.entries(bossPets)) {
|
||||
if (Object.hasOwn(bossKills, encounterId)) continue
|
||||
const id = Number(encounterId)
|
||||
if (!Number.isInteger(id)) continue
|
||||
insertBossStats.run(characterId, id, 0, clampInteger(rawPets, 0, 0, 1000000))
|
||||
}
|
||||
database.prepare(`
|
||||
INSERT INTO character_pvp_stats (character_id, matches_played, matches_won)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(
|
||||
characterId,
|
||||
clampInteger(save.pvpMatchesPlayed, 0, 0, 1000000),
|
||||
clampInteger(save.pvpMatchesWon, 0, 0, 1000000),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let syncedClassId = clampInteger(
|
||||
|
||||
+1022
-160
File diff suppressed because it is too large
Load Diff
+297
-30
@@ -19,11 +19,13 @@ import {
|
||||
syncCloudSave,
|
||||
type GameMode,
|
||||
} from './gameRepository'
|
||||
import { focusFirstControl } from './input.tsx'
|
||||
import { focusFirstControl, useGameAction } from './input.tsx'
|
||||
import { barFillStyle } from './components/barStyles'
|
||||
|
||||
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
|
||||
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
|
||||
const EquipmentScreen = lazy(() => import('./components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen })))
|
||||
const HunterProfileScreen = lazy(() => import('./components/HunterProfileScreen').then((module) => ({ default: module.HunterProfileScreen })))
|
||||
const PvPRoguelikeScreen = lazy(() => import('./components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen })))
|
||||
const PvpStadiumScreen = lazy(() => import('./components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen })))
|
||||
const TalentScreen = lazy(() => import('./components/TalentScreen').then((module) => ({ default: module.TalentScreen })))
|
||||
@@ -38,6 +40,7 @@ type Screen =
|
||||
| 'pvp'
|
||||
| 'customize'
|
||||
| 'equipment'
|
||||
| 'hunter-profile'
|
||||
| 'talents'
|
||||
| 'settings'
|
||||
|
||||
@@ -51,6 +54,7 @@ const MENU_ITEMS: Array<{
|
||||
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-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: 'hunter-profile', label: 'Hunter Profile', glyph: 'H', description: 'Review boss kills, PvP record, drops, and pets.' },
|
||||
{ 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.' },
|
||||
]
|
||||
@@ -58,6 +62,20 @@ const MENU_ITEMS: Array<{
|
||||
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
||||
const SHOW_LEADERBOARDS = false
|
||||
const ACTIVITY_PAGE_SIZE = 4
|
||||
const HOME_MENU_COLUMNS = 2
|
||||
const DUNGEON_NAV_COLUMNS = 2
|
||||
|
||||
type DungeonNavEntry =
|
||||
| { kind: 'spacer'; disabled: true }
|
||||
| { kind: 'back'; disabled?: boolean }
|
||||
| { kind: 'pagePrev'; disabled?: boolean }
|
||||
| { kind: 'pageNext'; disabled?: boolean }
|
||||
| { kind: 'activity'; index: number; disabled?: boolean }
|
||||
| { kind: 'tier'; index: number; disabled?: boolean }
|
||||
| { kind: 'start'; disabled?: boolean }
|
||||
| { kind: 'marathon'; disabled?: boolean }
|
||||
| { kind: 'loot'; disabled?: boolean }
|
||||
| { kind: 'lootSort'; disabled?: boolean }
|
||||
|
||||
function activityInitials(name: string) {
|
||||
return name
|
||||
@@ -111,6 +129,8 @@ function App() {
|
||||
const [error, setError] = useState('')
|
||||
const [syncingCloud, setSyncingCloud] = useState(false)
|
||||
const [syncMessage, setSyncMessage] = useState('')
|
||||
const [homeSelectedIndex, setHomeSelectedIndex] = useState(0)
|
||||
const [dungeonSelectedIndex, setDungeonSelectedIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
loadAuthSession()
|
||||
@@ -332,6 +352,214 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const cloudSync = getCloudSyncStatus()
|
||||
const canShowCloudSync = Boolean(account && account.id !== -1 && cloudSync.available)
|
||||
const homeMenuOffset = canShowCloudSync ? 1 : 0
|
||||
const homeMenuEntryCount = MENU_ITEMS.length + homeMenuOffset
|
||||
const homeActiveIndex = Math.min(homeSelectedIndex, homeMenuEntryCount - 1)
|
||||
|
||||
function openHomeMenuIndex(index: number) {
|
||||
if (canShowCloudSync && index === 0) {
|
||||
if (!syncingCloud && cloudSync.dirty) void syncSaveNow()
|
||||
return
|
||||
}
|
||||
const item = MENU_ITEMS[index - homeMenuOffset]
|
||||
if (!item) return
|
||||
if (item.screen === 'pvp') {
|
||||
setRoguelikeVariant('pvp')
|
||||
setScreen('roguelike')
|
||||
return
|
||||
}
|
||||
if (item.screen === 'dungeons' || item.screen === 'raids') {
|
||||
const nextOptions = item.screen === 'raids' ? raidOptions : dungeonOptions
|
||||
setActivityPage(0)
|
||||
setDungeonSelectedIndex(firstActivityDungeonEntryIndexForPageCount(
|
||||
Math.max(1, Math.ceil(nextOptions.length / ACTIVITY_PAGE_SIZE)),
|
||||
))
|
||||
}
|
||||
setScreen(item.screen)
|
||||
}
|
||||
|
||||
function dungeonEntries() {
|
||||
const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0]
|
||||
const locked = profile && difficulty ? profile.character.level < difficulty.unlockLevel : true
|
||||
const entries: DungeonNavEntry[] = [
|
||||
{ kind: 'back' },
|
||||
]
|
||||
if (activityPageCount > 1) {
|
||||
entries.push(
|
||||
{ kind: 'pagePrev', disabled: currentActivityPage === 0 },
|
||||
{ kind: 'pageNext', disabled: currentActivityPage >= activityPageCount - 1 },
|
||||
)
|
||||
}
|
||||
if (entries.length % DUNGEON_NAV_COLUMNS !== 0) {
|
||||
entries.push({ kind: 'spacer', disabled: true })
|
||||
}
|
||||
pagedActivityOptions.forEach((candidate, index) => {
|
||||
const candidateDifficulty = difficulty
|
||||
? candidate.difficulties.find(
|
||||
(option) => option.droppedItemLevel === difficulty.droppedItemLevel,
|
||||
) ?? candidate.difficulties[0]
|
||||
: candidate.difficulties[0]
|
||||
entries.push({
|
||||
kind: 'activity',
|
||||
index,
|
||||
disabled: !profile || profile.character.level < candidateDifficulty.unlockLevel,
|
||||
})
|
||||
})
|
||||
tierOptions.forEach((difficultyOption, index) => {
|
||||
entries.push({
|
||||
kind: 'tier',
|
||||
index,
|
||||
disabled: !profile || profile.character.level < difficultyOption.unlockLevel,
|
||||
})
|
||||
})
|
||||
entries.push(
|
||||
{ kind: 'start', disabled: locked },
|
||||
{ kind: 'marathon', disabled: locked },
|
||||
{ kind: 'loot' },
|
||||
)
|
||||
if (showLoot) entries.push({ kind: 'lootSort' })
|
||||
return entries
|
||||
}
|
||||
|
||||
function firstEnabledDungeonEntry(entries: DungeonNavEntry[]) {
|
||||
return Math.max(0, entries.findIndex((entry) => !entry.disabled))
|
||||
}
|
||||
|
||||
function firstActivityDungeonEntryIndexForPageCount(pageCount: number) {
|
||||
const entryCountBeforeActivities = pageCount > 1 ? 3 : 1
|
||||
return entryCountBeforeActivities % DUNGEON_NAV_COLUMNS === 0
|
||||
? entryCountBeforeActivities
|
||||
: entryCountBeforeActivities + 1
|
||||
}
|
||||
|
||||
function activeDungeonEntry(entries = dungeonEntries()) {
|
||||
if (entries[dungeonSelectedIndex] && !entries[dungeonSelectedIndex].disabled) {
|
||||
return entries[dungeonSelectedIndex]
|
||||
}
|
||||
const firstEnabled = firstEnabledDungeonEntry(entries)
|
||||
return entries[firstEnabled] ?? entries[0]
|
||||
}
|
||||
|
||||
function dungeonEntrySelected(entry: DungeonNavEntry['kind'], index?: number) {
|
||||
const active = activeDungeonEntry()
|
||||
return active?.kind === entry && ('index' in active ? active.index === index : index === undefined)
|
||||
}
|
||||
|
||||
function moveDungeonSelection(action: string) {
|
||||
const entries = dungeonEntries()
|
||||
if (entries.length === 0) return
|
||||
setDungeonSelectedIndex((current) => {
|
||||
const bounded = entries[current] && !entries[current].disabled
|
||||
? current
|
||||
: firstEnabledDungeonEntry(entries)
|
||||
const column = bounded % DUNGEON_NAV_COLUMNS
|
||||
const direction = action === 'navigateLeft' || action === 'navigateUp' ? -1 : 1
|
||||
const step = action === 'navigateUp' || action === 'navigateDown' ? DUNGEON_NAV_COLUMNS : 1
|
||||
if (action === 'navigateLeft' && column === 0) return bounded
|
||||
if (action === 'navigateRight' && column === DUNGEON_NAV_COLUMNS - 1) return bounded
|
||||
for (let next = bounded + direction * step; next >= 0 && next < entries.length; next += direction * step) {
|
||||
if (!entries[next].disabled) return next
|
||||
}
|
||||
return bounded
|
||||
})
|
||||
}
|
||||
|
||||
function selectActivityByPageIndex(index: number) {
|
||||
const candidate = pagedActivityOptions[index]
|
||||
if (!candidate) return
|
||||
const difficulty = selectedDifficultyOption
|
||||
? candidate.difficulties.find(
|
||||
(option) => option.droppedItemLevel === selectedDifficultyOption.droppedItemLevel,
|
||||
) ?? candidate.difficulties[0]
|
||||
: candidate.difficulties[0]
|
||||
if (profile && profile.character.level < difficulty.unlockLevel) return
|
||||
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||
else setSelectedDungeonId(candidate.id)
|
||||
setSelectedDifficultyId(difficulty.id)
|
||||
}
|
||||
|
||||
function selectTierByIndex(index: number) {
|
||||
const difficulty = tierOptions[index]
|
||||
const activity = selectedActivityOption ?? activityOptions[0]
|
||||
if (!difficulty || !activity || (profile && profile.character.level < difficulty.unlockLevel)) return
|
||||
setActivityPage(0)
|
||||
const nextActivity = activity.difficulties.some(
|
||||
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||
)
|
||||
? activity
|
||||
: activityOptions.find((option) =>
|
||||
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
|
||||
)
|
||||
if (!nextActivity) return
|
||||
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
|
||||
else setSelectedDungeonId(nextActivity.id)
|
||||
const nextDifficulty = nextActivity.difficulties.find(
|
||||
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||
)
|
||||
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
|
||||
}
|
||||
|
||||
function startSelectedRun(marathon: boolean) {
|
||||
const activity = selectedActivityOption ?? activityOptions[0]
|
||||
const difficulty = selectedDifficultyOption ?? activity?.difficulties[0]
|
||||
if (!activity || !difficulty || (profile && profile.character.level < difficulty.unlockLevel)) return
|
||||
setSelectedMarathonMode(marathon)
|
||||
setCombatContentId(activity.id)
|
||||
setSelectedDifficultyId(difficulty.id)
|
||||
setScreen('combat')
|
||||
}
|
||||
|
||||
function openDungeonEntry(entry: DungeonNavEntry | undefined) {
|
||||
if (!entry || entry.disabled) return
|
||||
if (entry.kind === 'back') setScreen('menu')
|
||||
else if (entry.kind === 'pagePrev') setActivityPage((page) => Math.max(0, page - 1))
|
||||
else if (entry.kind === 'pageNext') setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))
|
||||
else if (entry.kind === 'activity') selectActivityByPageIndex(entry.index)
|
||||
else if (entry.kind === 'tier') selectTierByIndex(entry.index)
|
||||
else if (entry.kind === 'start') startSelectedRun(false)
|
||||
else if (entry.kind === 'marathon') startSelectedRun(true)
|
||||
else if (entry.kind === 'loot') setShowLoot((current) => !current)
|
||||
else if (entry.kind === 'lootSort') setLootSort((current) => current === 'sequence' ? 'boss' : 'sequence')
|
||||
}
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (device !== 'controller') return
|
||||
if (screen === 'menu') {
|
||||
if (action === 'confirm') {
|
||||
openHomeMenuIndex(homeActiveIndex)
|
||||
return
|
||||
}
|
||||
if (!action.startsWith('navigate')) return
|
||||
setHomeSelectedIndex((current) => {
|
||||
const bounded = Math.min(current, homeMenuEntryCount - 1)
|
||||
const column = bounded % HOME_MENU_COLUMNS
|
||||
if (action === 'navigateLeft') return column > 0 ? bounded - 1 : bounded
|
||||
if (action === 'navigateRight') {
|
||||
const next = bounded + 1
|
||||
return column < HOME_MENU_COLUMNS - 1 && next < homeMenuEntryCount ? next : bounded
|
||||
}
|
||||
if (action === 'navigateUp') return bounded >= HOME_MENU_COLUMNS ? bounded - HOME_MENU_COLUMNS : bounded
|
||||
const next = bounded + HOME_MENU_COLUMNS
|
||||
if (next < homeMenuEntryCount) return next
|
||||
return column > 0 ? homeMenuEntryCount - 1 : bounded
|
||||
})
|
||||
return
|
||||
}
|
||||
if (screen === 'dungeons' || screen === 'raids') {
|
||||
if (action === 'back') {
|
||||
setScreen('menu')
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
openDungeonEntry(activeDungeonEntry())
|
||||
return
|
||||
}
|
||||
if (action.startsWith('navigate')) moveDungeonSelection(action)
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="game-shell">
|
||||
@@ -451,11 +679,9 @@ function App() {
|
||||
const activity = selectedActivityOption ?? dungeon
|
||||
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||
const cloudSync = getCloudSyncStatus()
|
||||
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
||||
|
||||
return (
|
||||
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''}`}>
|
||||
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
|
||||
{screen !== 'hunter-profile' && (
|
||||
<header className="topbar app-header">
|
||||
<button
|
||||
className="brand-button"
|
||||
@@ -469,19 +695,24 @@ function App() {
|
||||
<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}%` }} />
|
||||
<span style={barFillStyle(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">
|
||||
<section className="menu-screen" data-game-nav-active="true">
|
||||
<div className="main-menu-grid">
|
||||
{canShowCloudSync && (
|
||||
<div className="menu-card cloud-sync-card">
|
||||
<div
|
||||
className={`menu-card cloud-sync-card ${homeActiveIndex === 0 ? 'game-selected' : ''}`}
|
||||
data-game-selected={homeActiveIndex === 0 ? 'true' : undefined}
|
||||
onPointerDown={() => setHomeSelectedIndex(0)}
|
||||
>
|
||||
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
|
||||
<div>
|
||||
<strong>Cloud Save</strong>
|
||||
@@ -494,6 +725,7 @@ function App() {
|
||||
</div>
|
||||
<button
|
||||
className="text-button"
|
||||
data-controller-nav="skip"
|
||||
disabled={syncingCloud || !cloudSync.dirty}
|
||||
onClick={syncSaveNow}
|
||||
type="button"
|
||||
@@ -502,18 +734,16 @@ function App() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{MENU_ITEMS.map((item) => (
|
||||
{MENU_ITEMS.map((item, index) => {
|
||||
const homeIndex = index + homeMenuOffset
|
||||
return (
|
||||
<button
|
||||
className="menu-card"
|
||||
className={`menu-card ${homeActiveIndex === homeIndex ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={homeActiveIndex === homeIndex ? 'true' : undefined}
|
||||
key={item.screen}
|
||||
onClick={() => {
|
||||
if (item.screen === 'pvp') {
|
||||
setRoguelikeVariant('pvp')
|
||||
setScreen('roguelike')
|
||||
return
|
||||
}
|
||||
setScreen(item.screen)
|
||||
}}
|
||||
onClick={() => openHomeMenuIndex(homeIndex)}
|
||||
onPointerDown={() => setHomeSelectedIndex(homeIndex)}
|
||||
type="button"
|
||||
>
|
||||
<span>{item.glyph}</span>
|
||||
@@ -522,7 +752,8 @@ function App() {
|
||||
<small>{item.description}</small>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -728,7 +959,7 @@ function App() {
|
||||
)}
|
||||
|
||||
{(screen === 'dungeons' || screen === 'raids') && (
|
||||
<section className="content-screen dungeon-run-screen">
|
||||
<section className="content-screen dungeon-run-screen" data-game-nav-active="true">
|
||||
<div className="dungeon-run-board">
|
||||
<div className="dungeon-run-main">
|
||||
<article className="run-summary-card dungeon-focus-card">
|
||||
@@ -739,7 +970,15 @@ function App() {
|
||||
<p className="eyebrow">Selected Run</p>
|
||||
<div className="run-title-row">
|
||||
<h2>{activity.name}</h2>
|
||||
<button className="back-button inline-back-button" onClick={() => setScreen('menu')} type="button">Back</button>
|
||||
<button
|
||||
className={`back-button inline-back-button ${dungeonEntrySelected('back') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
onClick={() => setScreen('menu')}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'back'))}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<p>{activity.description}</p>
|
||||
<div className="tag-row">
|
||||
@@ -761,16 +1000,22 @@ function App() {
|
||||
{activityPageCount > 1 ? (
|
||||
<div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
|
||||
<button
|
||||
className={dungeonEntrySelected('pagePrev') ? 'game-selected' : ''}
|
||||
data-controller-nav="skip"
|
||||
disabled={currentActivityPage === 0}
|
||||
onClick={() => setActivityPage((page) => Math.max(0, page - 1))}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'pagePrev'))}
|
||||
type="button"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span>{activityPageStart}-{activityPageEnd} of {activityOptions.length}</span>
|
||||
<button
|
||||
className={dungeonEntrySelected('pageNext') ? 'game-selected' : ''}
|
||||
data-controller-nav="skip"
|
||||
disabled={currentActivityPage >= activityPageCount - 1}
|
||||
onClick={() => setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'pageNext'))}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
@@ -781,7 +1026,7 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
<div className="activity-card-grid dungeon-choice-grid">
|
||||
{pagedActivityOptions.map((candidate) => {
|
||||
{pagedActivityOptions.map((candidate, index) => {
|
||||
const difficulty = candidate.difficulties.find(
|
||||
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
||||
) ?? candidate.difficulties[0]
|
||||
@@ -789,7 +1034,8 @@ function App() {
|
||||
const selected = candidate.id === activity.id
|
||||
return (
|
||||
<button
|
||||
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('activity', index) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={locked}
|
||||
key={candidate.id}
|
||||
onClick={() => {
|
||||
@@ -797,6 +1043,7 @@ function App() {
|
||||
else setSelectedDungeonId(candidate.id)
|
||||
setSelectedDifficultyId(difficulty.id)
|
||||
}}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'activity' && 'index' in entry && entry.index === index))}
|
||||
type="button"
|
||||
>
|
||||
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||
@@ -824,12 +1071,13 @@ function App() {
|
||||
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
|
||||
</div>
|
||||
<div className="tier-grid">
|
||||
{tierOptions.map((difficulty) => {
|
||||
{tierOptions.map((difficulty, index) => {
|
||||
const locked = profile.character.level < difficulty.unlockLevel
|
||||
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
||||
return (
|
||||
<button
|
||||
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('tier', index) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={locked}
|
||||
key={difficulty.id}
|
||||
onClick={() => {
|
||||
@@ -850,6 +1098,7 @@ function App() {
|
||||
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
|
||||
}
|
||||
}}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'tier' && 'index' in entry && entry.index === index))}
|
||||
type="button"
|
||||
>
|
||||
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
||||
@@ -874,7 +1123,8 @@ function App() {
|
||||
</div>
|
||||
<div className="part-picker">
|
||||
<button
|
||||
className="primary-button selected-part"
|
||||
className={`primary-button selected-part ${dungeonEntrySelected('start') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={difficultyLocked}
|
||||
onClick={() => {
|
||||
setSelectedMarathonMode(false)
|
||||
@@ -882,12 +1132,14 @@ function App() {
|
||||
setSelectedDifficultyId(selectedDifficulty.id)
|
||||
setScreen('combat')
|
||||
}}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'start'))}
|
||||
type="button"
|
||||
>
|
||||
Start Hunt
|
||||
</button>
|
||||
<button
|
||||
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''}`}
|
||||
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${dungeonEntrySelected('marathon') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={difficultyLocked}
|
||||
onClick={() => {
|
||||
setSelectedMarathonMode(true)
|
||||
@@ -895,6 +1147,7 @@ function App() {
|
||||
setSelectedDifficultyId(selectedDifficulty.id)
|
||||
setScreen('combat')
|
||||
}}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'marathon'))}
|
||||
type="button"
|
||||
>
|
||||
Marathon
|
||||
@@ -924,8 +1177,10 @@ function App() {
|
||||
<h2>{selectedDifficulty.name} Loot Tables</h2>
|
||||
</div>
|
||||
<button
|
||||
className="text-button"
|
||||
className={`text-button ${dungeonEntrySelected('loot') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
onClick={() => setShowLoot((current) => !current)}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'loot'))}
|
||||
type="button"
|
||||
>
|
||||
{showLoot ? 'Hide Loot' : 'View Loot'}
|
||||
@@ -934,9 +1189,14 @@ function App() {
|
||||
{showLoot && (
|
||||
<>
|
||||
<div className="loot-toolbar">
|
||||
<label>
|
||||
<label className={dungeonEntrySelected('lootSort') ? 'game-selected' : ''}>
|
||||
<span>Sort</span>
|
||||
<select value={lootSort} onChange={(event) => setLootSort(event.target.value as 'sequence' | 'boss')}>
|
||||
<select
|
||||
data-controller-nav="skip"
|
||||
onChange={(event) => setLootSort(event.target.value as 'sequence' | 'boss')}
|
||||
onPointerDown={() => setDungeonSelectedIndex(dungeonEntries().findIndex((entry) => entry.kind === 'lootSort'))}
|
||||
value={lootSort}
|
||||
>
|
||||
<option value="sequence">Encounter order</option>
|
||||
<option value="boss">Boss name</option>
|
||||
</select>
|
||||
@@ -1090,6 +1350,13 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen === 'hunter-profile' && (
|
||||
<HunterProfileScreen
|
||||
profile={profile}
|
||||
onBack={() => setScreen('menu')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen === 'settings' && (
|
||||
<SettingsScreen onBack={() => setScreen('menu')} />
|
||||
)}
|
||||
|
||||
@@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './App.css'
|
||||
import { AdminScreen } from './components/AdminScreen'
|
||||
import { InputProvider } from './input'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<InputProvider>
|
||||
<AdminScreen onBack={() => window.close()} />
|
||||
</InputProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,21 +1,79 @@
|
||||
import type { Spell } from '../game'
|
||||
|
||||
export function cooldownNow() {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
export function cooldownRemaining(
|
||||
cooldowns: Record<string, number>,
|
||||
spellId: string,
|
||||
now = cooldownNow(),
|
||||
) {
|
||||
return Math.max(0, ((cooldowns[spellId] ?? 0) - now) / 1000)
|
||||
}
|
||||
|
||||
export function hasActiveCooldowns(
|
||||
cooldowns: Record<string, number>,
|
||||
now = cooldownNow(),
|
||||
) {
|
||||
return Object.values(cooldowns).some((readyAtMs) => readyAtMs > now)
|
||||
}
|
||||
|
||||
export function pruneExpiredCooldowns(
|
||||
cooldowns: Record<string, number>,
|
||||
now = cooldownNow(),
|
||||
) {
|
||||
let changed = false
|
||||
const nextCooldowns: Record<string, number> = {}
|
||||
for (const id in cooldowns) {
|
||||
if (cooldowns[id] <= now) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
nextCooldowns[id] = cooldowns[id]
|
||||
}
|
||||
return changed ? nextCooldowns : cooldowns
|
||||
}
|
||||
|
||||
export function reduceCooldown(
|
||||
cooldowns: Record<string, number>,
|
||||
spellId: string,
|
||||
seconds: number,
|
||||
now = cooldownNow(),
|
||||
) {
|
||||
const readyAtMs = cooldowns[spellId]
|
||||
if (!readyAtMs || readyAtMs <= now) return cooldowns
|
||||
const nextReadyAtMs = readyAtMs - seconds * 1000
|
||||
const nextCooldowns = { ...cooldowns }
|
||||
if (nextReadyAtMs <= now) delete nextCooldowns[spellId]
|
||||
else nextCooldowns[spellId] = nextReadyAtMs
|
||||
return nextCooldowns
|
||||
}
|
||||
|
||||
export function canCastSpell(
|
||||
spell: Spell,
|
||||
resource: number,
|
||||
cooldowns: Record<string, number>,
|
||||
resourceCost: number,
|
||||
now = cooldownNow(),
|
||||
) {
|
||||
return resource >= resourceCost && (cooldowns[spell.id] ?? 0) <= 0
|
||||
return resource >= resourceCost && cooldownRemaining(cooldowns, spell.id, now) <= 0
|
||||
}
|
||||
|
||||
export function putSpellOnCooldown(
|
||||
cooldowns: Record<string, number>,
|
||||
spell: Spell,
|
||||
cooldownMultiplier = 1,
|
||||
now = cooldownNow(),
|
||||
) {
|
||||
const cooldownMs = spell.cooldown * cooldownMultiplier * 1000
|
||||
if (cooldownMs <= 0) {
|
||||
const nextCooldowns = { ...cooldowns }
|
||||
delete nextCooldowns[spell.id]
|
||||
return nextCooldowns
|
||||
}
|
||||
return {
|
||||
...cooldowns,
|
||||
[spell.id]: spell.cooldown * cooldownMultiplier,
|
||||
[spell.id]: now + cooldownMs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
completeDungeon,
|
||||
completeRoguelike,
|
||||
loadProfile,
|
||||
recordBossKill,
|
||||
type DungeonReward,
|
||||
rollEncounterLoot,
|
||||
type LootRoll,
|
||||
@@ -46,7 +47,7 @@ import {
|
||||
spellPowerMultiplier,
|
||||
spellResourceCost as modifiedSpellResourceCost,
|
||||
} from '../combat/spellModifiers'
|
||||
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
|
||||
import { regenerateResource } from '../combat/combatTick'
|
||||
import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine'
|
||||
import { applyCastStateUpdate } from '../combat/combatStateTransitions'
|
||||
import { buildCombatDualScreenState } from '../combat/dualScreenPayloads'
|
||||
@@ -58,7 +59,11 @@ import {
|
||||
appendCombatLog,
|
||||
type BasicFloatingCombatText,
|
||||
} from '../combat/combatPresentation'
|
||||
import { canCastSpell } from '../combat/spellCasting'
|
||||
import {
|
||||
canCastSpell,
|
||||
pruneExpiredCooldowns,
|
||||
reduceCooldown,
|
||||
} from '../combat/spellCasting'
|
||||
import {
|
||||
applySpellEffectProfile,
|
||||
buildSpellTargetPlan,
|
||||
@@ -69,9 +74,11 @@ import {
|
||||
useInput,
|
||||
} from '../input'
|
||||
import { useFloatingCombatText } from '../hooks/useFloatingCombatText'
|
||||
import { useSpellSlots } from '../hooks/useSpellSlots'
|
||||
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
||||
import { PartyMemberFrame } from './PartyFrames'
|
||||
import { ResourceBar, SpellBar, type SpellSlot } from './SpellBars'
|
||||
import { ResourceBar, SpellBar } from './SpellBars'
|
||||
import { barFillStyle } from './barStyles'
|
||||
import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
|
||||
import { ResultScreen } from './ResultScreen'
|
||||
import {
|
||||
@@ -365,6 +372,7 @@ export function CombatScreen({
|
||||
const rewardClaimedRef = useRef(false)
|
||||
const profileRefreshedRef = useRef(false)
|
||||
const rolledEncounterIdsRef = useRef(new Set<string>())
|
||||
const recordedBossKillIdsRef = useRef(new Set<string>())
|
||||
const runTokenRef = useRef(crypto.randomUUID())
|
||||
const resourceSpentRef = useRef(0)
|
||||
const runStartedAtRef = useRef(0)
|
||||
@@ -510,6 +518,12 @@ export function CombatScreen({
|
||||
: `${result.encounterName} dropped no components.`,
|
||||
result.dropped ? 'loot' : 'system',
|
||||
)
|
||||
if (result.petAwarded) {
|
||||
addLog(
|
||||
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
|
||||
'loot',
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
addLog(
|
||||
@@ -521,6 +535,29 @@ export function CombatScreen({
|
||||
[addLog, difficulty.id],
|
||||
)
|
||||
|
||||
const recordRoguelikeBossKill = useCallback((encounter: DungeonEncounter) => {
|
||||
if (!isRoguelike || !encounter.isBoss) return
|
||||
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
|
||||
if (recordedBossKillIdsRef.current.has(key)) return
|
||||
recordedBossKillIdsRef.current.add(key)
|
||||
recordBossKill(encounter.id, { petVariant: 'purple' })
|
||||
.then((result) => {
|
||||
onProfileUpdated(result.profile)
|
||||
if (result.petAwarded) {
|
||||
addLog(
|
||||
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
|
||||
'loot',
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
addLog(
|
||||
reason instanceof Error ? reason.message : 'Unable to record boss kill.',
|
||||
'danger',
|
||||
)
|
||||
})
|
||||
}, [addLog, encounterIndex, isRoguelike, onProfileUpdated])
|
||||
|
||||
const resetRun = useCallback(() => {
|
||||
const nextRoguelikeEncounters = roguelikeMode
|
||||
? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode)
|
||||
@@ -552,6 +589,7 @@ export function CombatScreen({
|
||||
rewardClaimedRef.current = false
|
||||
profileRefreshedRef.current = false
|
||||
rolledEncounterIdsRef.current = new Set()
|
||||
recordedBossKillIdsRef.current = new Set()
|
||||
runTokenRef.current = crypto.randomUUID()
|
||||
marathonBossesDefeatedRef.current = 0
|
||||
resourceSpentRef.current = setup.defaults.resourceSpent
|
||||
@@ -668,10 +706,10 @@ export function CombatScreen({
|
||||
})
|
||||
floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value))
|
||||
resourceSpentRef.current += effectiveCost
|
||||
const nextCooldowns = { ...current.cooldowns }
|
||||
let nextCooldowns = current.cooldowns
|
||||
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
|
||||
const radiance = spellByName.get('Radiance')
|
||||
if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2)
|
||||
if (radiance) nextCooldowns = reduceCooldown(nextCooldowns, radiance.id, 2)
|
||||
}
|
||||
setCombat(applyCastStateUpdate({
|
||||
current,
|
||||
@@ -855,7 +893,7 @@ export function CombatScreen({
|
||||
const runCombatTick = useCallback(() => {
|
||||
const current = combatRef.current
|
||||
const nextElapsedTicks = current.elapsedTicks + 1
|
||||
const nextCooldowns = advanceCooldowns(current.cooldowns, tickSeconds(TICK_MS))
|
||||
const nextCooldowns = pruneExpiredCooldowns(current.cooldowns)
|
||||
let nextResource = regenerateResource(current.resource, 2.4, maxResource)
|
||||
|
||||
const living = current.party.filter((member) => member.health > 0)
|
||||
@@ -985,6 +1023,7 @@ export function CombatScreen({
|
||||
requestLootRoll(encounter.id, rollIndex)
|
||||
}
|
||||
}
|
||||
recordRoguelikeBossKill(encounter)
|
||||
|
||||
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
||||
setCombat({
|
||||
@@ -1094,6 +1133,7 @@ export function CombatScreen({
|
||||
maxResource,
|
||||
gameClass.resourceName,
|
||||
requestLootRoll,
|
||||
recordRoguelikeBossKill,
|
||||
profile.character.name,
|
||||
setCombat,
|
||||
startPart,
|
||||
@@ -1161,17 +1201,17 @@ export function CombatScreen({
|
||||
}).reverse(),
|
||||
[enemyCount, enemyHealth, encounter.maxHealth],
|
||||
)
|
||||
const spellSlots = useMemo<SpellSlot[]>(() => profile.abilitySlots.map((abilityId, slotIndex) => {
|
||||
const spell = spellByKey.get(String(slotIndex + 1))
|
||||
return abilityId && spell
|
||||
? {
|
||||
...spell,
|
||||
cost: spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
|
||||
slotIndex,
|
||||
remaining: cooldowns[spell.id] ?? 0,
|
||||
}
|
||||
: null
|
||||
}), [cooldowns, freeCastReady, profile.abilitySlots, roguelikeUpgradeCounts, spellByKey])
|
||||
const spellSlotCost = useCallback(
|
||||
(spell: Spell) => spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
|
||||
[freeCastReady, roguelikeUpgradeCounts],
|
||||
)
|
||||
const spellSlots = useSpellSlots({
|
||||
spells,
|
||||
abilitySlots: profile.abilitySlots,
|
||||
cooldowns,
|
||||
active: status === 'playing' && !paused,
|
||||
cost: spellSlotCost,
|
||||
})
|
||||
const dualScreenState = useMemo(() => buildCombatDualScreenState({
|
||||
difficultyName: difficulty.name,
|
||||
dungeonName: dungeon.name,
|
||||
@@ -1232,7 +1272,7 @@ export function CombatScreen({
|
||||
return (
|
||||
<main
|
||||
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
|
||||
data-combat-active={paused ? 'false' : 'true'}
|
||||
data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
|
||||
>
|
||||
{!dualScreenEnabled && <header className="topbar">
|
||||
<div>
|
||||
@@ -1266,13 +1306,13 @@ export function CombatScreen({
|
||||
<div className="hard-enemy-bars">
|
||||
{enemyHealthSegments.map((segment) => (
|
||||
<div className="bar enemy-health" key={segment.index}>
|
||||
<span style={{ width: `${segment.percent}%` }} />
|
||||
<span style={barFillStyle(segment.percent)} />
|
||||
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
|
||||
<div className="bar enemy-health"><span style={barFillStyle(enemyPercent)} /></div>
|
||||
)}
|
||||
<p>{encounter.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||
|
||||
type HunterProfileScreenProps = {
|
||||
profile: CharacterProfile
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
type BossEntry = {
|
||||
encounter: DungeonEncounter
|
||||
dungeonId: number
|
||||
dungeonName: string
|
||||
contentType: 'dungeon' | 'raid'
|
||||
}
|
||||
|
||||
type CollectionItem = {
|
||||
key: string
|
||||
glyph: string
|
||||
name: string
|
||||
chance: string
|
||||
quantity: number
|
||||
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||
source: string
|
||||
}
|
||||
|
||||
function bossInitials(name: string) {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
function dropChanceLabel(chance: number) {
|
||||
if (chance <= 0) return 'Unavailable'
|
||||
const denominator = Math.round(1 / chance)
|
||||
if (denominator >= 10) return `1 in ${denominator}`
|
||||
return `${Math.round(chance * 100)}%`
|
||||
}
|
||||
|
||||
function purplePetKey(encounterId: number) {
|
||||
return `purple:${encounterId}`
|
||||
}
|
||||
|
||||
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
|
||||
const [activeTab, setActiveTab] = useState<'stats' | 'collection'>('stats')
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
|
||||
dungeon.encounters
|
||||
.filter((encounter) => encounter.isBoss)
|
||||
.map((encounter) => ({
|
||||
encounter,
|
||||
dungeonId: dungeon.id,
|
||||
dungeonName: dungeon.name,
|
||||
contentType: dungeon.contentType,
|
||||
})),
|
||||
), [profile.dungeons])
|
||||
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
|
||||
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
|
||||
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
|
||||
const bossKills = profile.hunterStats?.bossKills ?? {}
|
||||
const bossPets = profile.hunterStats?.bossPets ?? {}
|
||||
const inventoryQuantities = useMemo(
|
||||
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
|
||||
[profile.inventory],
|
||||
)
|
||||
const totalBossKills = bosses.reduce((total, boss) => total + (bossKills[String(boss.encounter.id)] ?? 0), 0)
|
||||
const mostKilledBoss = bosses.reduce<BossEntry | null>((best, boss) => {
|
||||
if (!best) return boss
|
||||
return (bossKills[String(boss.encounter.id)] ?? 0) > (bossKills[String(best.encounter.id)] ?? 0)
|
||||
? boss
|
||||
: best
|
||||
}, null)
|
||||
const matchesPlayed = profile.hunterStats?.pvpMatchesPlayed ?? 0
|
||||
const matchesWon = profile.hunterStats?.pvpMatchesWon ?? 0
|
||||
const winRate = matchesPlayed > 0 ? Math.round((matchesWon / matchesPlayed) * 100) : 0
|
||||
const collectionItems = useMemo<CollectionItem[]>(() => {
|
||||
if (!selectedBoss) return []
|
||||
return [
|
||||
{
|
||||
key: `kills:${selectedBoss.encounter.id}`,
|
||||
glyph: 'K',
|
||||
name: 'Boss Kills',
|
||||
chance: 'Defeats',
|
||||
quantity: bossKills[String(selectedBoss.encounter.id)] ?? 0,
|
||||
rarity: 'stat',
|
||||
source: selectedBoss.encounter.enemyName,
|
||||
},
|
||||
...selectedBoss.encounter.lootTables.map((drop) => ({
|
||||
key: `drop:${drop.difficultyId}:${drop.id}`,
|
||||
glyph: drop.glyph,
|
||||
name: drop.name,
|
||||
chance: dropChanceLabel(drop.dropChance),
|
||||
quantity: inventoryQuantities.get(drop.id) ?? 0,
|
||||
rarity: drop.rarity,
|
||||
source: selectedBoss.encounter.enemyName,
|
||||
})),
|
||||
{
|
||||
key: `pet:${selectedBoss.encounter.id}`,
|
||||
glyph: '*',
|
||||
name: `${selectedBoss.encounter.enemyName} Pet`,
|
||||
chance: '1 in 500',
|
||||
quantity: bossPets[String(selectedBoss.encounter.id)] ?? 0,
|
||||
rarity: 'legendary',
|
||||
source: selectedBoss.encounter.enemyName,
|
||||
},
|
||||
{
|
||||
key: `purple-pet:${selectedBoss.encounter.id}`,
|
||||
glyph: 'P',
|
||||
name: `Purple ${selectedBoss.encounter.enemyName} Pet`,
|
||||
chance: '1 in 500',
|
||||
quantity: bossPets[purplePetKey(selectedBoss.encounter.id)] ?? 0,
|
||||
rarity: 'epic',
|
||||
source: `${selectedBoss.encounter.enemyName} Roguelike`,
|
||||
},
|
||||
]
|
||||
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
|
||||
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
|
||||
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
|
||||
return {
|
||||
mode: 'collection',
|
||||
title: focusedItem.name,
|
||||
subtitle: selectedBoss.encounter.enemyName,
|
||||
summary: `Owned x${focusedItem.quantity}`,
|
||||
items: [
|
||||
{
|
||||
glyph: focusedItem.glyph,
|
||||
title: 'Drop Rate',
|
||||
meta: focusedItem.chance,
|
||||
detail: focusedItem.source,
|
||||
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
|
||||
},
|
||||
{
|
||||
title: 'Boss Kills',
|
||||
meta: `${bossKills[String(selectedBoss.encounter.id)] ?? 0}`,
|
||||
detail: selectedBoss.dungeonName,
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [activeTab, bossKills, focusedItem, selectedBoss])
|
||||
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
|
||||
|
||||
return (
|
||||
<section className="content-screen hunter-profile-screen">
|
||||
<div className="screen-heading hunter-profile-heading">
|
||||
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
|
||||
<button
|
||||
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'stats'}
|
||||
>
|
||||
Stats
|
||||
</button>
|
||||
<button
|
||||
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('collection')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'collection'}
|
||||
>
|
||||
Collection Log
|
||||
</button>
|
||||
</div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'stats' && (
|
||||
<div className="hunter-stat-grid">
|
||||
<article className="hunter-stat-tile">
|
||||
<span>Total Boss Kills</span>
|
||||
<strong>{totalBossKills}</strong>
|
||||
</article>
|
||||
<article className="hunter-stat-tile">
|
||||
<span>Most Killed Boss</span>
|
||||
<strong>{totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None'}</strong>
|
||||
<small>{totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills'}</small>
|
||||
</article>
|
||||
<article className="hunter-stat-tile">
|
||||
<span>PvP Matches</span>
|
||||
<strong>{matchesPlayed}</strong>
|
||||
</article>
|
||||
<article className="hunter-stat-tile">
|
||||
<span>PvP Wins</span>
|
||||
<strong>{matchesWon}</strong>
|
||||
</article>
|
||||
<article className="hunter-stat-tile">
|
||||
<span>PvP Win Rate</span>
|
||||
<strong>{winRate}%</strong>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'collection' && (
|
||||
<div className="collection-log-layout">
|
||||
<div className="collection-boss-list" aria-label="Bosses">
|
||||
{bosses.map((boss) => {
|
||||
return (
|
||||
<button
|
||||
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''}`}
|
||||
key={boss.encounter.id}
|
||||
onClick={() => setSelectedBossId(boss.encounter.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>{bossInitials(boss.encounter.enemyName)}</span>
|
||||
<strong>{boss.encounter.enemyName}</strong>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedBoss && (
|
||||
<article className="collection-boss-detail">
|
||||
<div className="collection-drop-list">
|
||||
{collectionItems.map((item) => (
|
||||
<button
|
||||
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'}`}
|
||||
key={item.key}
|
||||
onClick={() => setFocusedItemKey(item.key)}
|
||||
onFocus={() => setFocusedItemKey(item.key)}
|
||||
type="button"
|
||||
>
|
||||
<span>{item.glyph}</span>
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.chance}</small>
|
||||
<b>x{item.quantity}</b>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
memberHotEffects,
|
||||
} from '../combat/rules'
|
||||
import { ControllerBindingLabel } from './ControllerIcons'
|
||||
import { barFillStyle } from './barStyles'
|
||||
|
||||
export type FloatingCombatText = {
|
||||
id: number
|
||||
@@ -131,8 +132,8 @@ export const PartyMemberFrame = memo(function PartyMemberFrame({
|
||||
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
|
||||
</div>
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / maxHealth) * 100}%` }} />
|
||||
{member.shield > 0 && <i style={{ width: `${(member.shield / maxHealth) * 100}%` }} />}
|
||||
<span style={barFillStyle((member.health / maxHealth) * 100)} />
|
||||
{member.shield > 0 && <i style={barFillStyle((member.shield / maxHealth) * 100, 'right')} />}
|
||||
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
|
||||
</div>
|
||||
<div className="floating-combat-texts" aria-hidden="true">
|
||||
|
||||
@@ -9,13 +9,15 @@ import {
|
||||
type PartyMember,
|
||||
type Spell,
|
||||
} from '../game'
|
||||
import { completeRoguelike, type DungeonReward } from '../profile'
|
||||
import { completeRoguelike, recordPvpMatch, type DungeonReward } from '../profile'
|
||||
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
||||
import type { GameMode } from '../gameRepository'
|
||||
import { PartyMemberFrame } from './PartyFrames'
|
||||
import { SpellBar, type SpellSlot } from './SpellBars'
|
||||
import { SpellBar } from './SpellBars'
|
||||
import { barFillStyle } from './barStyles'
|
||||
import { focusFirstControl, useGameAction, useInput } from '../input'
|
||||
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
|
||||
import { useSpellSlots } from '../hooks/useSpellSlots'
|
||||
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
|
||||
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
||||
import {
|
||||
@@ -37,10 +39,14 @@ import {
|
||||
spellExtraTargets,
|
||||
spellResourceCost as modifiedSpellResourceCost,
|
||||
} from '../combat/spellModifiers'
|
||||
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
|
||||
import { regenerateResource } from '../combat/combatTick'
|
||||
import { advanceMemberTick } from '../combat/combatEngine'
|
||||
import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads'
|
||||
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
|
||||
import {
|
||||
pruneExpiredCooldowns,
|
||||
reduceCooldown,
|
||||
} from '../combat/spellCasting'
|
||||
import {
|
||||
nextPvpRoguelikeStage,
|
||||
resolvePvpRoguelikeCombatOutcome,
|
||||
@@ -356,6 +362,7 @@ export function PvPRoguelikeScreen({
|
||||
const nextLogId = useRef(2)
|
||||
const elapsedTicksRef = useRef(0)
|
||||
const recordedRunRef = useRef(false)
|
||||
const matchStatsRecordedRef = useRef(false)
|
||||
const rewardClaimedRef = useRef(false)
|
||||
const matchWinRewardClaimedRef = useRef(false)
|
||||
const bossRewardClaimedRef = useRef(new Set<number>())
|
||||
@@ -398,12 +405,16 @@ export function PvPRoguelikeScreen({
|
||||
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
|
||||
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
||||
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
|
||||
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
|
||||
...spell,
|
||||
cost: spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
|
||||
slotIndex,
|
||||
remaining: playerSide.cooldowns[spell.id] ?? 0,
|
||||
})), [playerBuffCounts, playerDebuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
|
||||
const playerSpellSlotCost = useCallback(
|
||||
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
|
||||
[playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady],
|
||||
)
|
||||
const playerSpellSlots = useSpellSlots({
|
||||
spells: starterSpells,
|
||||
cooldowns: playerSide.cooldowns,
|
||||
active: status === 'playing' && !paused,
|
||||
cost: playerSpellSlotCost,
|
||||
})
|
||||
const opponentBuffSummary = useMemo(
|
||||
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
|
||||
[cpuSide.buffs, selfBuffChoicesCatalog],
|
||||
@@ -528,6 +539,12 @@ export function PvPRoguelikeScreen({
|
||||
'loot',
|
||||
)
|
||||
}
|
||||
if (result.petAwarded) {
|
||||
addLog(
|
||||
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
|
||||
'loot',
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
setRewardError(
|
||||
@@ -644,6 +661,7 @@ export function PvPRoguelikeScreen({
|
||||
pendingLiveUpgradeRef.current = null
|
||||
loggedOpponentDoneRef.current = false
|
||||
recordedRunRef.current = false
|
||||
matchStatsRecordedRef.current = false
|
||||
rewardClaimedRef.current = false
|
||||
matchWinRewardClaimedRef.current = false
|
||||
cpuDefeatedRef.current = false
|
||||
@@ -701,6 +719,7 @@ export function PvPRoguelikeScreen({
|
||||
pendingLiveUpgradeRef.current = null
|
||||
loggedOpponentDoneRef.current = false
|
||||
recordedRunRef.current = false
|
||||
matchStatsRecordedRef.current = false
|
||||
rewardClaimedRef.current = false
|
||||
matchWinRewardClaimedRef.current = false
|
||||
cpuDefeatedRef.current = false
|
||||
@@ -880,9 +899,9 @@ export function PvPRoguelikeScreen({
|
||||
damageReductionTargets: new Set<string>(),
|
||||
groupTargets,
|
||||
}
|
||||
const nextCooldowns = { ...current.cooldowns }
|
||||
let nextCooldowns = current.cooldowns
|
||||
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
|
||||
nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2)
|
||||
nextCooldowns = reduceCooldown(nextCooldowns, radianceEffect.id, 2)
|
||||
}
|
||||
return applyPvpSpellCast({
|
||||
current,
|
||||
@@ -994,7 +1013,7 @@ export function PvPRoguelikeScreen({
|
||||
...side,
|
||||
party: nextParty,
|
||||
resource: regenerateResource(side.resource, 2.4, maxResource),
|
||||
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)),
|
||||
cooldowns: pruneExpiredCooldowns(side.cooldowns),
|
||||
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
||||
}
|
||||
}, [activeSpellEffects, addFloatingHeal, maxResource])
|
||||
@@ -1086,6 +1105,20 @@ export function PvPRoguelikeScreen({
|
||||
})
|
||||
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'won' && status !== 'lost') return
|
||||
if (matchStatsRecordedRef.current) return
|
||||
matchStatsRecordedRef.current = true
|
||||
recordPvpMatch(status === 'won')
|
||||
.then(onProfileUpdated)
|
||||
.catch((reason: unknown) => {
|
||||
addLog(
|
||||
reason instanceof Error ? reason.message : 'Unable to record PvP match.',
|
||||
'danger',
|
||||
)
|
||||
})
|
||||
}, [addLog, onProfileUpdated, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'upgrade-choice') return
|
||||
window.requestAnimationFrame(() => focusFirstControl())
|
||||
@@ -1339,6 +1372,9 @@ export function PvPRoguelikeScreen({
|
||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
||||
opponentParty: cpuSide.party,
|
||||
opponentEnemyHealth: cpuSide.enemyHealth,
|
||||
opponentResource: cpuSide.resource,
|
||||
opponentMaxResource: maxResource,
|
||||
opponentResourceName: gameClass.resourceName,
|
||||
opponentBuffSummary,
|
||||
opponentDebuffSummary,
|
||||
floatingTexts: dualScreenFloatingTexts,
|
||||
@@ -1362,6 +1398,7 @@ export function PvPRoguelikeScreen({
|
||||
cpuDifficulty,
|
||||
cpuSide.enemyHealth,
|
||||
cpuSide.party,
|
||||
cpuSide.resource,
|
||||
directPartyTargeting,
|
||||
encounter.description,
|
||||
encounter.enemyName,
|
||||
@@ -1424,13 +1461,13 @@ export function PvPRoguelikeScreen({
|
||||
<div className="pvp-clear-wrap">
|
||||
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||
<span style={barFillStyle((playerSide.enemyHealth / encounter.maxHealth) * 100)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
|
||||
<div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / maxResource) * 100)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1464,12 +1501,12 @@ export function PvPRoguelikeScreen({
|
||||
<div className="pvp-clear-wrap">
|
||||
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
||||
<span style={barFillStyle((cpuSide.enemyHealth / encounter.maxHealth) * 100)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
|
||||
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div>
|
||||
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / maxResource) * 100)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1495,6 +1532,7 @@ export function PvPRoguelikeScreen({
|
||||
bindings={activeBindings}
|
||||
canCast={status === 'playing' && !playerDone && playerAlive}
|
||||
className="pvp-bottom-spell-bar"
|
||||
focusable={false}
|
||||
iconStyle={controllerIconStyle}
|
||||
onCast={castPlayerSpell}
|
||||
resource={playerSide.resource}
|
||||
|
||||
@@ -7,13 +7,15 @@ import {
|
||||
type PartyMember,
|
||||
type Spell,
|
||||
} from '../game'
|
||||
import { completeRoguelike } from '../profile'
|
||||
import { completeRoguelike, recordPvpMatch } from '../profile'
|
||||
import type { CharacterProfile } from '../profile'
|
||||
import type { GameMode } from '../gameRepository'
|
||||
import { PartyMemberFrame } from './PartyFrames'
|
||||
import { SpellBar, type SpellSlot } from './SpellBars'
|
||||
import { SpellBar } from './SpellBars'
|
||||
import { barFillStyle } from './barStyles'
|
||||
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
|
||||
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
|
||||
import { useSpellSlots } from '../hooks/useSpellSlots'
|
||||
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
|
||||
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
||||
import { useDualScreen, useDualScreenPublisher } from '../dualScreen'
|
||||
@@ -31,10 +33,13 @@ import {
|
||||
spellPowerMultiplier,
|
||||
spellResourceCost as modifiedSpellResourceCost,
|
||||
} from '../combat/spellModifiers'
|
||||
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
|
||||
import { regenerateResource } from '../combat/combatTick'
|
||||
import { advanceMemberTick } from '../combat/combatEngine'
|
||||
import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads'
|
||||
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
|
||||
import {
|
||||
pruneExpiredCooldowns,
|
||||
} from '../combat/spellCasting'
|
||||
import {
|
||||
appendCombatLog,
|
||||
STADIUM_COMBAT_LOG_LIMIT,
|
||||
@@ -386,6 +391,7 @@ export function PvpStadiumScreen({
|
||||
const nextLogId = useRef(2)
|
||||
const submittedShopRef = useRef(false)
|
||||
const awardedXpRef = useRef(new Set<string>())
|
||||
const matchStatsRecordedRef = useRef(false)
|
||||
const queuedMatchRef = useRef(false)
|
||||
const roundResolvedRef = useRef(false)
|
||||
const loggedOpponentRoundRef = useRef('')
|
||||
@@ -400,12 +406,16 @@ export function PvpStadiumScreen({
|
||||
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
||||
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
|
||||
...spell,
|
||||
cost: spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
|
||||
slotIndex,
|
||||
remaining: playerSide.cooldowns[spell.id] ?? 0,
|
||||
})), [playerBuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
|
||||
const playerSpellSlotCost = useCallback(
|
||||
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
|
||||
[playerBuffCounts, playerSide.freeCastReady],
|
||||
)
|
||||
const playerSpellSlots = useSpellSlots({
|
||||
spells: starterSpells,
|
||||
cooldowns: playerSide.cooldowns,
|
||||
active: status === 'playing' && !paused,
|
||||
cost: playerSpellSlotCost,
|
||||
})
|
||||
const opponentBuffSummary = useMemo(
|
||||
() => summarizeStacks(cpuSide.buffs, buffCatalog),
|
||||
[buffCatalog, cpuSide.buffs],
|
||||
@@ -496,6 +506,7 @@ export function PvpStadiumScreen({
|
||||
queuedMatchRef.current = true
|
||||
nextLogId.current = 2
|
||||
awardedXpRef.current = new Set()
|
||||
matchStatsRecordedRef.current = false
|
||||
roundResolvedRef.current = false
|
||||
setPlayerSide(setup.playerSide)
|
||||
setCpuSide(setup.opponentSide)
|
||||
@@ -532,6 +543,7 @@ export function PvpStadiumScreen({
|
||||
queuedMatchRef.current = true
|
||||
nextLogId.current = 2
|
||||
awardedXpRef.current = new Set()
|
||||
matchStatsRecordedRef.current = false
|
||||
roundResolvedRef.current = false
|
||||
setPlayerSide(setup.playerSide)
|
||||
setCpuSide(setup.opponentSide)
|
||||
@@ -790,7 +802,7 @@ export function PvpStadiumScreen({
|
||||
...side,
|
||||
party: nextParty,
|
||||
resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE),
|
||||
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)),
|
||||
cooldowns: pruneExpiredCooldowns(side.cooldowns),
|
||||
survivalSeconds: nextSurvival,
|
||||
dampeningPercent,
|
||||
}
|
||||
@@ -873,6 +885,20 @@ export function PvpStadiumScreen({
|
||||
return () => window.clearInterval(timer)
|
||||
}, [advanceBoss, cpuTakeTurn, finishRound, paused, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'won' && status !== 'lost') return
|
||||
if (matchStatsRecordedRef.current) return
|
||||
matchStatsRecordedRef.current = true
|
||||
recordPvpMatch(status === 'won')
|
||||
.then(onProfileUpdated)
|
||||
.catch((reason: unknown) => {
|
||||
addLog(
|
||||
reason instanceof Error ? reason.message : 'Unable to record PvP match.',
|
||||
'danger',
|
||||
)
|
||||
})
|
||||
}, [addLog, onProfileUpdated, status])
|
||||
|
||||
const startNextRound = useCallback(() => {
|
||||
const nextRound = roundIndex + 1
|
||||
const nextPlayer = createStadiumStarterSide<StadiumBuffId>({
|
||||
@@ -1055,6 +1081,9 @@ export function PvpStadiumScreen({
|
||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
||||
opponentParty: cpuSide.party,
|
||||
opponentEnemyHealth: 0,
|
||||
opponentResource: cpuSide.resource,
|
||||
opponentMaxResource: MAX_RESOURCE,
|
||||
opponentResourceName: gameClass.resourceName,
|
||||
opponentBuffSummary,
|
||||
opponentDebuffSummary,
|
||||
floatingTexts: dualScreenFloatingTexts,
|
||||
@@ -1083,6 +1112,7 @@ export function PvpStadiumScreen({
|
||||
controllerIconStyle,
|
||||
cpuDifficulty,
|
||||
cpuSide.party,
|
||||
cpuSide.resource,
|
||||
cpuSide.survivalSeconds,
|
||||
directPartyTargeting,
|
||||
dualScreenFloatingTexts,
|
||||
@@ -1164,16 +1194,16 @@ export function PvpStadiumScreen({
|
||||
</section>
|
||||
|
||||
<section className="dual-top-spell-strip">
|
||||
{starterSpells.map((spell, slotIndex) => {
|
||||
const remaining = playerSide.cooldowns[spell.id] ?? 0
|
||||
const cost = spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady)
|
||||
{playerSpellSlots.map((spell, slotIndex) => {
|
||||
if (!spell) return null
|
||||
const remaining = spell.remaining
|
||||
const percent = remaining > 0
|
||||
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
|
||||
: 0
|
||||
return (
|
||||
<button
|
||||
className="dual-top-spell"
|
||||
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < cost || paused}
|
||||
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < spell.cost || paused}
|
||||
key={spell.id}
|
||||
onClick={() => castPlayerSpell(spell)}
|
||||
type="button"
|
||||
@@ -1188,7 +1218,7 @@ export function PvpStadiumScreen({
|
||||
<div className="dual-top-resource">
|
||||
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
|
||||
<div className="bar mana-bar">
|
||||
<span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} />
|
||||
<span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1222,7 +1252,7 @@ export function PvpStadiumScreen({
|
||||
</div>
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span>
|
||||
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} /></div>
|
||||
<div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="party-grid pvp-party-grid">
|
||||
@@ -1252,7 +1282,7 @@ export function PvpStadiumScreen({
|
||||
</div>
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
||||
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / MAX_RESOURCE) * 100}%` }} /></div>
|
||||
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="party-grid pvp-party-grid">
|
||||
@@ -1276,6 +1306,7 @@ export function PvpStadiumScreen({
|
||||
bindings={activeBindings}
|
||||
canCast={status === 'playing' && playerAlive}
|
||||
className="pvp-bottom-spell-bar"
|
||||
focusable={false}
|
||||
iconStyle={controllerIconStyle}
|
||||
onCast={castPlayerSpell}
|
||||
resource={playerSide.resource}
|
||||
|
||||
@@ -100,14 +100,15 @@ export function LootRollList({
|
||||
return (
|
||||
<div className="run-loot-rolls">
|
||||
{rolls.map((roll) => (
|
||||
<div className={roll.dropped ? 'dropped' : 'empty'} key={roll.encounterId}>
|
||||
<div className={roll.dropped || roll.petAwarded ? 'dropped' : 'empty'} key={roll.encounterId}>
|
||||
<strong>{roll.encounterName}</strong>
|
||||
<span>
|
||||
{roll.items.length > 0
|
||||
? roll.items
|
||||
.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`)
|
||||
.join(', ')
|
||||
: 'No components dropped'}
|
||||
{[
|
||||
...roll.items.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`),
|
||||
...(roll.petAwarded
|
||||
? [`* ${roll.petAwarded.petName}${roll.petAwarded.duplicate ? ` (owned x${roll.petAwarded.quantityAfter})` : ''}`]
|
||||
: []),
|
||||
].join(', ') || 'No components dropped'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -32,11 +32,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
capture,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
combatTouchLocked,
|
||||
beginCapture,
|
||||
cancelCapture,
|
||||
resetBindings,
|
||||
setControllerIconStyle,
|
||||
setDirectPartyTargeting,
|
||||
setCombatTouchLocked,
|
||||
} = useInput()
|
||||
const {
|
||||
enabled: dualScreenEnabled,
|
||||
@@ -89,14 +91,8 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
|
||||
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>
|
||||
|
||||
<div className="settings-nav">
|
||||
<button className="back-button settings-back-button" onClick={onBack} type="button">Back</button>
|
||||
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
|
||||
{([
|
||||
{ key: 'display', label: 'Display' },
|
||||
@@ -115,6 +111,7 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{settingsTab === 'display' && (
|
||||
<section className="dual-screen-settings settings-tab-panel">
|
||||
@@ -180,6 +177,14 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
>
|
||||
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={combatTouchLocked}
|
||||
className={combatTouchLocked ? 'selected' : ''}
|
||||
onClick={() => setCombatTouchLocked(!combatTouchLocked)}
|
||||
type="button"
|
||||
>
|
||||
{combatTouchLocked ? 'Combat Touch Locked' : 'Combat Touch Unlocked'}
|
||||
</button>
|
||||
<div className="controller-icon-options">
|
||||
<span>Controller Icons</span>
|
||||
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo } from 'react'
|
||||
import type { ControllerIconStyle } from '../input'
|
||||
import type { Spell } from '../game'
|
||||
import { ControllerBindingLabel } from './ControllerIcons'
|
||||
import { barFillStyle } from './barStyles'
|
||||
|
||||
export type SpellSlot = (Spell & {
|
||||
cost: number
|
||||
@@ -28,7 +29,7 @@ export const ResourceBar = memo(function ResourceBar({
|
||||
{unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`}
|
||||
</span>
|
||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
||||
<div className="bar mana-bar"><span style={barFillStyle((resource / maxResource) * 100)} /></div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -39,6 +40,7 @@ export const SpellButton = memo(function SpellButton({
|
||||
iconStyle,
|
||||
resourceName,
|
||||
disabled,
|
||||
focusable = true,
|
||||
onCast,
|
||||
emptyKeyPrefix = 'empty',
|
||||
}: {
|
||||
@@ -47,6 +49,7 @@ export const SpellButton = memo(function SpellButton({
|
||||
iconStyle: ControllerIconStyle
|
||||
resourceName: string
|
||||
disabled?: boolean
|
||||
focusable?: boolean
|
||||
onCast: (spell: Spell) => void
|
||||
emptyKeyPrefix?: string
|
||||
}) {
|
||||
@@ -60,9 +63,11 @@ export const SpellButton = memo(function SpellButton({
|
||||
return (
|
||||
<button
|
||||
className="spell"
|
||||
data-controller-nav={focusable ? undefined : 'skip'}
|
||||
disabled={disabled}
|
||||
key={spell.id}
|
||||
onClick={() => onCast(spell)}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
title={spell.description}
|
||||
type="button"
|
||||
>
|
||||
@@ -94,6 +99,7 @@ export const SpellBar = memo(function SpellBar({
|
||||
canCast,
|
||||
onCast,
|
||||
className = 'spell-bar six-slots vertical-spell-bar',
|
||||
focusable = true,
|
||||
}: {
|
||||
spells: SpellSlot[]
|
||||
bindings: Record<string, string>
|
||||
@@ -103,6 +109,7 @@ export const SpellBar = memo(function SpellBar({
|
||||
canCast: boolean
|
||||
onCast: (spell: Spell) => void
|
||||
className?: string
|
||||
focusable?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -118,6 +125,7 @@ export const SpellBar = memo(function SpellBar({
|
||||
<SpellButton
|
||||
binding={bindings[`ability${slotIndex + 1}`]}
|
||||
disabled={!canCast || resource < spell.cost || spell.remaining > 0}
|
||||
focusable={focusable}
|
||||
iconStyle={iconStyle}
|
||||
key={spell.id}
|
||||
onCast={onCast}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
function clampScale(value: number) {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
export function barFillStyle(
|
||||
percent: number,
|
||||
origin: 'left' | 'right' = 'left',
|
||||
): CSSProperties {
|
||||
return {
|
||||
transform: `scaleX(${clampScale(percent / 100)})`,
|
||||
transformOrigin: `${origin} center`,
|
||||
}
|
||||
}
|
||||
+87
-33
@@ -19,9 +19,11 @@ import {
|
||||
dispatchExternalGameAction,
|
||||
type ControllerIconStyle,
|
||||
type InputAction,
|
||||
useGameAction,
|
||||
} from './input'
|
||||
import { ControllerBindingLabel } from './components/ControllerIcons'
|
||||
import { PartyMemberFrame } from './components/PartyFrames'
|
||||
import { barFillStyle } from './components/barStyles'
|
||||
import { groupFloatingTextsByMember } from './combat/combatPresentation'
|
||||
|
||||
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
|
||||
@@ -47,6 +49,9 @@ export type DualScreenCombatState = {
|
||||
opponentClassName?: string
|
||||
opponentParty?: PartyMember[]
|
||||
opponentEnemyHealth?: number
|
||||
opponentResource?: number
|
||||
opponentMaxResource?: number
|
||||
opponentResourceName?: string
|
||||
opponentBuffSummary?: string
|
||||
opponentDebuffSummary?: string
|
||||
floatingTexts: Array<{
|
||||
@@ -79,7 +84,7 @@ export type DualScreenCombatState = {
|
||||
}
|
||||
|
||||
export type DualScreenWorkshopState = {
|
||||
mode: 'class' | 'equipment' | 'crafting' | 'talents'
|
||||
mode: 'class' | 'equipment' | 'crafting' | 'talents' | 'collection'
|
||||
title: string
|
||||
subtitle: string
|
||||
summary?: string
|
||||
@@ -145,6 +150,16 @@ function formatDualTime(seconds: number) {
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function shouldRelayBottomAction(
|
||||
action: InputAction,
|
||||
state: DualScreenCombatState | null,
|
||||
) {
|
||||
if (!state) return false
|
||||
if (action === 'confirm' || action === 'toggleTouchLock') return false
|
||||
if (state.status === 'playing') return true
|
||||
return action === 'pause' || action === 'back'
|
||||
}
|
||||
|
||||
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||
const [enabled, setEnabledState] = useState(
|
||||
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
||||
@@ -398,6 +413,11 @@ export function DualScreenBottomDisplay() {
|
||||
channel?.close()
|
||||
}
|
||||
|
||||
useGameAction((action) => {
|
||||
if (!shouldRelayBottomAction(action, state)) return
|
||||
sendAction(action)
|
||||
})
|
||||
|
||||
if (!state && workshopState) {
|
||||
return (
|
||||
<main className="dual-bottom-display workshop-bottom-display">
|
||||
@@ -445,7 +465,10 @@ export function DualScreenBottomDisplay() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}>
|
||||
<main
|
||||
className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}
|
||||
data-combat-active={state.status === 'playing' && !state.paused ? 'true' : 'false'}
|
||||
>
|
||||
<header className="dual-controls-header">
|
||||
<div>
|
||||
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
|
||||
@@ -477,8 +500,19 @@ export function DualScreenBottomDisplay() {
|
||||
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
|
||||
</div>
|
||||
<div className="bar enemy-health boss-bar">
|
||||
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
|
||||
<span style={barFillStyle(((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)} />
|
||||
</div>
|
||||
{typeof state.opponentResource === 'number' && state.opponentMaxResource ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="eyebrow">{state.opponentResourceName ?? state.resourceName}</p>
|
||||
<strong>{Math.floor(state.opponentResource)} / {state.opponentMaxResource}</strong>
|
||||
</div>
|
||||
<div className="bar mana-bar">
|
||||
<span style={barFillStyle((state.opponentResource / state.opponentMaxResource) * 100)} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -513,7 +547,7 @@ export function DualScreenBottomDisplay() {
|
||||
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
||||
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar">
|
||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -626,8 +660,35 @@ export function DualScreenTopCombat({
|
||||
[state.floatingTexts],
|
||||
)
|
||||
|
||||
const spellButtons = state.spells.map((spell, slotIndex) => {
|
||||
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
|
||||
const percent = spell.remaining > 0
|
||||
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
|
||||
: 0
|
||||
return (
|
||||
<div className="dual-top-main">
|
||||
<button
|
||||
className="dual-top-spell"
|
||||
data-controller-nav={state.opponentParty ? 'skip' : undefined}
|
||||
disabled={
|
||||
!state.playerIsAlive
|
||||
|| state.resource < spell.cost
|
||||
|| spell.remaining > 0
|
||||
|| state.status !== 'playing'
|
||||
|| state.paused
|
||||
}
|
||||
key={spell.id}
|
||||
onClick={() => onCastSpell?.(spell)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
||||
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
|
||||
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={`dual-top-main ${state.opponentParty ? 'pvp-roguelike-dual-top' : ''}`}>
|
||||
<section className="dual-top-enemy">
|
||||
<div className="enemy-portrait" aria-hidden="true">
|
||||
{state.encounterIsBoss ? 'B' : 'M'}
|
||||
@@ -638,10 +699,25 @@ export function DualScreenTopCombat({
|
||||
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
|
||||
</div>
|
||||
<div className="bar enemy-health">
|
||||
<span style={{ width: `${enemyPercent}%` }} />
|
||||
<span style={barFillStyle(enemyPercent)} />
|
||||
</div>
|
||||
<p>{state.encounterDescription}</p>
|
||||
{state.opponentParty && (
|
||||
<div className="dual-top-resource">
|
||||
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
||||
<div className="bar mana-bar">
|
||||
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!state.opponentParty && <p>{state.encounterDescription}</p>}
|
||||
</div>
|
||||
{state.opponentParty && (
|
||||
<div className="dual-top-side-status">
|
||||
<div className="dual-top-cooldowns" aria-label="Cooldowns">
|
||||
{spellButtons}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dual-top-party">
|
||||
@@ -669,39 +745,17 @@ export function DualScreenTopCombat({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!state.opponentParty && (
|
||||
<section className="dual-top-spell-strip">
|
||||
{state.spells.map((spell, slotIndex) => {
|
||||
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
|
||||
const percent = spell.remaining > 0
|
||||
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
|
||||
: 0
|
||||
return (
|
||||
<button
|
||||
className="dual-top-spell"
|
||||
disabled={
|
||||
!state.playerIsAlive
|
||||
|| state.resource < spell.cost
|
||||
|| spell.remaining > 0
|
||||
|| state.status !== 'playing'
|
||||
|| state.paused
|
||||
}
|
||||
key={spell.id}
|
||||
onClick={() => onCastSpell?.(spell)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
||||
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
|
||||
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{spellButtons}
|
||||
<div className="dual-top-resource">
|
||||
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
||||
<div className="bar mana-bar">
|
||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
+151
-10
@@ -17,6 +17,7 @@ import type {
|
||||
LootRoll,
|
||||
Item,
|
||||
EquipmentSlot,
|
||||
PetAward,
|
||||
} from './profile'
|
||||
|
||||
export type GameMode = 'online' | 'offline'
|
||||
@@ -64,6 +65,14 @@ export interface GameRepository {
|
||||
difficultyId: number,
|
||||
runToken: string,
|
||||
): Promise<LootRoll>
|
||||
recordBossKill(
|
||||
encounterId: number,
|
||||
options?: { petVariant?: 'normal' | 'purple' },
|
||||
): Promise<{
|
||||
profile: CharacterProfile
|
||||
petAwarded: PetAward | null
|
||||
}>
|
||||
recordPvpMatch(won: boolean): Promise<CharacterProfile>
|
||||
}
|
||||
|
||||
type CharacterData = {
|
||||
@@ -76,7 +85,7 @@ type CharacterData = {
|
||||
}
|
||||
|
||||
type OfflineSave = {
|
||||
version: 3
|
||||
version: 4
|
||||
characterName: string
|
||||
activeClassId: number
|
||||
completedDungeonParts: number
|
||||
@@ -84,6 +93,10 @@ type OfflineSave = {
|
||||
dungeonCompletions?: Record<string, number>
|
||||
characters: Record<number, CharacterData>
|
||||
lootRolls: Record<string, LootRoll>
|
||||
bossKills: Record<string, number>
|
||||
bossPets: Record<string, number>
|
||||
pvpMatchesPlayed: number
|
||||
pvpMatchesWon: number
|
||||
}
|
||||
|
||||
type OnlineCache = {
|
||||
@@ -124,6 +137,7 @@ const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
|
||||
const authTokenKey = 'chronicle.authToken.v1'
|
||||
const offlineAccount = { id: -1, username: 'Offline' }
|
||||
const ABILITY_SLOT_COUNT = 6
|
||||
const BOSS_PET_DROP_RATE = 1 / 500
|
||||
let activeCatalogCache: CatalogCache | null = null
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
@@ -175,7 +189,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 3,
|
||||
version: 4,
|
||||
characterName: p.character.name,
|
||||
activeClassId: p.character.classId,
|
||||
completedDungeonParts: p.completedDungeonParts,
|
||||
@@ -185,14 +199,33 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
||||
),
|
||||
characters,
|
||||
lootRolls: v1.lootRolls ?? {},
|
||||
bossKills: p.hunterStats?.bossKills ?? {},
|
||||
bossPets: p.hunterStats?.bossPets ?? {},
|
||||
pvpMatchesPlayed: p.hunterStats?.pvpMatchesPlayed ?? 0,
|
||||
pvpMatchesWon: p.hunterStats?.pvpMatchesWon ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
|
||||
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 }): OfflineSave {
|
||||
return normalizeSaveAbilitySlots({
|
||||
...v2,
|
||||
version: 3,
|
||||
version: 4,
|
||||
completedRaidPhases: 0,
|
||||
bossKills: {},
|
||||
bossPets: {},
|
||||
pvpMatchesPlayed: 0,
|
||||
pvpMatchesWon: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function upgradeV3Save(v3: Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 }): OfflineSave {
|
||||
return normalizeSaveAbilitySlots({
|
||||
...v3,
|
||||
version: 4,
|
||||
bossKills: {},
|
||||
bossPets: {},
|
||||
pvpMatchesPlayed: 0,
|
||||
pvpMatchesWon: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -224,9 +257,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
||||
profile?: CharacterProfile
|
||||
lootRolls?: Record<string, LootRoll>
|
||||
}
|
||||
if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave)
|
||||
if (candidate.version === 4) return normalizeSaveAbilitySlots(candidate as OfflineSave)
|
||||
if (candidate.version === 3) {
|
||||
return upgradeV3Save(candidate as Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 })
|
||||
}
|
||||
if (candidate.version === 2) {
|
||||
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
|
||||
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 })
|
||||
}
|
||||
if (candidate.version === 1 && candidate.profile) {
|
||||
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
|
||||
@@ -241,7 +277,7 @@ function readSaveKey(key: string): OfflineSave | null {
|
||||
const raw = JSON.parse(serialized)
|
||||
const save = normalizeOfflineSave(raw)
|
||||
if (!save) return null
|
||||
if (raw.version !== 3) {
|
||||
if (raw.version !== 4) {
|
||||
localStorage.setItem(key, JSON.stringify(save))
|
||||
}
|
||||
return save
|
||||
@@ -284,7 +320,7 @@ function readOnlineCache(): OnlineCache | null {
|
||||
save,
|
||||
dirty: Boolean(raw.dirty),
|
||||
}
|
||||
if ((raw.save as { version?: number } | undefined)?.version !== 3) {
|
||||
if ((raw.save as { version?: number } | undefined)?.version !== 4) {
|
||||
writeOnlineCache(cache)
|
||||
}
|
||||
return cache
|
||||
@@ -380,6 +416,12 @@ function buildProfile(save: OfflineSave): CharacterProfile {
|
||||
...dungeon,
|
||||
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
|
||||
}))
|
||||
static_.hunterStats = {
|
||||
bossKills: clone(save.bossKills ?? {}),
|
||||
bossPets: clone(save.bossPets ?? {}),
|
||||
pvpMatchesPlayed: save.pvpMatchesPlayed ?? 0,
|
||||
pvpMatchesWon: save.pvpMatchesWon ?? 0,
|
||||
}
|
||||
|
||||
return static_
|
||||
}
|
||||
@@ -547,7 +589,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
||||
inventory: clone(profile.inventory),
|
||||
}
|
||||
return {
|
||||
version: 3,
|
||||
version: 4,
|
||||
characterName: profile.character.name,
|
||||
activeClassId: profile.character.classId,
|
||||
completedDungeonParts: profile.completedDungeonParts,
|
||||
@@ -557,6 +599,10 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
||||
),
|
||||
characters,
|
||||
lootRolls: clone(existingSave?.lootRolls ?? {}),
|
||||
bossKills: clone(profile.hunterStats?.bossKills ?? existingSave?.bossKills ?? {}),
|
||||
bossPets: clone(profile.hunterStats?.bossPets ?? existingSave?.bossPets ?? {}),
|
||||
pvpMatchesPlayed: profile.hunterStats?.pvpMatchesPlayed ?? existingSave?.pvpMatchesPlayed ?? 0,
|
||||
pvpMatchesWon: profile.hunterStats?.pvpMatchesWon ?? existingSave?.pvpMatchesWon ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,6 +616,50 @@ function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]):
|
||||
return entries[entries.length - 1]
|
||||
}
|
||||
|
||||
function bossPetName(encounterName: string) {
|
||||
return `${encounterName} Pet`
|
||||
}
|
||||
|
||||
function purpleBossPetName(encounterName: string) {
|
||||
return `Purple ${encounterName} Pet`
|
||||
}
|
||||
|
||||
function maybeAwardBossPet(
|
||||
save: OfflineSave,
|
||||
encounter: { id: number; enemyName: string },
|
||||
variant: 'normal' | 'purple' = 'normal',
|
||||
): PetAward | null {
|
||||
if (Math.random() >= BOSS_PET_DROP_RATE) return null
|
||||
const key = variant === 'purple' ? `purple:${encounter.id}` : String(encounter.id)
|
||||
const previousQuantity = save.bossPets?.[key] ?? 0
|
||||
save.bossPets = {
|
||||
...(save.bossPets ?? {}),
|
||||
[key]: previousQuantity + 1,
|
||||
}
|
||||
return {
|
||||
encounterId: encounter.id,
|
||||
petName: variant === 'purple'
|
||||
? purpleBossPetName(encounter.enemyName)
|
||||
: bossPetName(encounter.enemyName),
|
||||
quantity: 1,
|
||||
duplicate: previousQuantity > 0,
|
||||
quantityAfter: previousQuantity + 1,
|
||||
}
|
||||
}
|
||||
|
||||
function recordBossKillInSave(
|
||||
save: OfflineSave,
|
||||
encounter: { id: number; enemyName: string },
|
||||
options?: { petVariant?: 'normal' | 'purple' },
|
||||
): PetAward | null {
|
||||
const key = String(encounter.id)
|
||||
save.bossKills = {
|
||||
...(save.bossKills ?? {}),
|
||||
[key]: (save.bossKills?.[key] ?? 0) + 1,
|
||||
}
|
||||
return maybeAwardBossPet(save, encounter, options?.petVariant ?? 'normal')
|
||||
}
|
||||
|
||||
function awardRoguelikeCoin(
|
||||
profile: CharacterProfile,
|
||||
sourceEncounterId: number | undefined,
|
||||
@@ -885,6 +975,10 @@ const serverRepository: GameRepository = {
|
||||
cachedOnlineLocalRepository.upgradeItem(itemId),
|
||||
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
|
||||
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
|
||||
recordBossKill: (encounterId, options) =>
|
||||
cachedOnlineLocalRepository.recordBossKill(encounterId, options),
|
||||
recordPvpMatch: (won) =>
|
||||
cachedOnlineLocalRepository.recordPvpMatch(won),
|
||||
}
|
||||
|
||||
function emptyCharacterData(classId: number): CharacterData {
|
||||
@@ -1172,6 +1266,17 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
profile.maxTalentPoints,
|
||||
cd.talentPoints + levelsGained,
|
||||
)
|
||||
let petAwarded: PetAward | null = null
|
||||
if (options?.lootSourceEncounterId) {
|
||||
const sourceEncounter = profile.dungeons
|
||||
.flatMap((candidate) => candidate.encounters)
|
||||
.find((candidate) => candidate.id === options.lootSourceEncounterId && candidate.isBoss)
|
||||
if (sourceEncounter) {
|
||||
petAwarded = recordBossKillInSave(save, sourceEncounter, {
|
||||
petVariant: 'purple',
|
||||
})
|
||||
}
|
||||
}
|
||||
const bonusItem = awardRoguelikeCoin(
|
||||
profile,
|
||||
options?.lootSourceEncounterId,
|
||||
@@ -1196,6 +1301,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
|
||||
unlockedAbilities,
|
||||
bonusItem,
|
||||
petAwarded,
|
||||
profile: updatedProfile,
|
||||
}
|
||||
},
|
||||
@@ -1446,6 +1552,9 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
throw new Error('This encounter has no configured loot.')
|
||||
}
|
||||
const dropChance = entries[0].dropChance
|
||||
const petAwarded = encounter.isBoss
|
||||
? recordBossKillInSave(save, encounter)
|
||||
: null
|
||||
const items: LootRoll['items'] = []
|
||||
|
||||
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
|
||||
@@ -1497,12 +1606,36 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
awarded: Boolean(item),
|
||||
duplicate: items.some((candidate) => candidate.duplicate),
|
||||
quantityAfter: item?.quantityAfter ?? 0,
|
||||
petAwarded,
|
||||
}
|
||||
save.lootRolls[rollKey] = result
|
||||
save.characters[save.activeClassId].inventory = profile.inventory
|
||||
store.writeSave(save)
|
||||
return clone(result)
|
||||
},
|
||||
async recordBossKill(encounterId, options) {
|
||||
const save = requireStoredSave(store)
|
||||
const profile = buildProfile(save)
|
||||
const encounter = profile.dungeons
|
||||
.flatMap((dungeon) => dungeon.encounters)
|
||||
.find((candidate) => candidate.id === encounterId && candidate.isBoss)
|
||||
if (!encounter) throw new Error('Boss encounter not found.')
|
||||
const petAwarded = recordBossKillInSave(save, encounter, {
|
||||
petVariant: options?.petVariant ?? 'normal',
|
||||
})
|
||||
store.writeSave(save)
|
||||
return {
|
||||
profile: buildProfile(save),
|
||||
petAwarded,
|
||||
}
|
||||
},
|
||||
async recordPvpMatch(won) {
|
||||
const save = requireStoredSave(store)
|
||||
save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1
|
||||
if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1
|
||||
store.writeSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1581,6 +1714,10 @@ const cachedOnlineRepository: GameRepository = {
|
||||
upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
|
||||
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
|
||||
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
|
||||
recordBossKill: (encounterId, options) =>
|
||||
cachedOnlineLocalRepository.recordBossKill(encounterId, options),
|
||||
recordPvpMatch: (won) =>
|
||||
cachedOnlineLocalRepository.recordPvpMatch(won),
|
||||
}
|
||||
|
||||
export function getGameMode(): GameMode {
|
||||
@@ -1627,13 +1764,17 @@ export function createOfflineCharacter(characterName: string): AuthSession {
|
||||
characters[cid] = emptyCharacterData(cid)
|
||||
}
|
||||
const save: OfflineSave = {
|
||||
version: 3,
|
||||
version: 4,
|
||||
characterName: name,
|
||||
activeClassId: 1,
|
||||
completedDungeonParts: 0,
|
||||
completedRaidPhases: 0,
|
||||
characters,
|
||||
lootRolls: {},
|
||||
bossKills: {},
|
||||
bossPets: {},
|
||||
pvpMatchesPlayed: 0,
|
||||
pvpMatchesWon: 0,
|
||||
}
|
||||
writeOfflineSave(save)
|
||||
writeMode('offline-local')
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cooldownNow, hasActiveCooldowns } from '../combat/spellCasting'
|
||||
|
||||
export function useCooldownClock(
|
||||
cooldowns: Record<string, number>,
|
||||
active: boolean,
|
||||
intervalMs = 100,
|
||||
) {
|
||||
const [now, setNow] = useState(cooldownNow)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !hasActiveCooldowns(cooldowns)) return undefined
|
||||
|
||||
let timer = 0
|
||||
let startTimer = 0
|
||||
const tick = () => {
|
||||
const nextNow = cooldownNow()
|
||||
setNow(nextNow)
|
||||
if (timer && !hasActiveCooldowns(cooldowns, nextNow)) {
|
||||
window.clearInterval(timer)
|
||||
timer = 0
|
||||
}
|
||||
}
|
||||
|
||||
startTimer = window.setTimeout(() => {
|
||||
tick()
|
||||
timer = window.setInterval(tick, intervalMs)
|
||||
}, 0)
|
||||
return () => {
|
||||
window.clearTimeout(startTimer)
|
||||
if (timer) window.clearInterval(timer)
|
||||
}
|
||||
}, [active, cooldowns, intervalMs])
|
||||
|
||||
return now
|
||||
}
|
||||
@@ -15,29 +15,31 @@ type FloatingCombatTextOptions = {
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
const FLOATING_TEXT_CLEANUP_INTERVAL_MS = 100
|
||||
|
||||
export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
|
||||
durationMs = 900,
|
||||
}: FloatingCombatTextOptions = {}) {
|
||||
const [floatingTexts, setFloatingTexts] = useState<TText[]>([])
|
||||
const nextId = useRef(1)
|
||||
const expirationsRef = useRef(new Map<number, number>())
|
||||
const cleanupTimerRef = useRef<number | null>(null)
|
||||
|
||||
const clearFloatingTexts = useCallback(() => {
|
||||
expirationsRef.current.clear()
|
||||
setFloatingTexts([])
|
||||
const clearCleanupTimer = useCallback(() => {
|
||||
if (cleanupTimerRef.current === null) return
|
||||
window.clearTimeout(cleanupTimerRef.current)
|
||||
cleanupTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
|
||||
if (entry.value <= 0) return
|
||||
const id = nextId.current++
|
||||
expirationsRef.current.set(id, performance.now() + durationMs)
|
||||
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
|
||||
}, [durationMs])
|
||||
const scheduleCleanup = useCallback(() => {
|
||||
function scheduleNext() {
|
||||
clearCleanupTimer()
|
||||
let nextExpiry = Infinity
|
||||
for (const expiresAt of expirationsRef.current.values()) {
|
||||
nextExpiry = Math.min(nextExpiry, expiresAt)
|
||||
}
|
||||
if (!Number.isFinite(nextExpiry)) return
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
cleanupTimerRef.current = window.setTimeout(() => {
|
||||
cleanupTimerRef.current = null
|
||||
const now = performance.now()
|
||||
let removed = false
|
||||
for (const [id, expiresAt] of expirationsRef.current) {
|
||||
@@ -45,11 +47,31 @@ export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
|
||||
expirationsRef.current.delete(id)
|
||||
removed = true
|
||||
}
|
||||
if (!removed) return
|
||||
if (removed) {
|
||||
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
|
||||
}, FLOATING_TEXT_CLEANUP_INTERVAL_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
}
|
||||
scheduleNext()
|
||||
}, Math.max(0, nextExpiry - performance.now()))
|
||||
}
|
||||
|
||||
scheduleNext()
|
||||
}, [clearCleanupTimer])
|
||||
|
||||
const clearFloatingTexts = useCallback(() => {
|
||||
clearCleanupTimer()
|
||||
expirationsRef.current.clear()
|
||||
setFloatingTexts([])
|
||||
}, [clearCleanupTimer])
|
||||
|
||||
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
|
||||
if (entry.value <= 0) return
|
||||
const id = nextId.current++
|
||||
expirationsRef.current.set(id, performance.now() + durationMs)
|
||||
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
|
||||
scheduleCleanup()
|
||||
}, [durationMs, scheduleCleanup])
|
||||
|
||||
useEffect(() => () => clearCleanupTimer(), [clearCleanupTimer])
|
||||
|
||||
const floatingTextsByMember = useMemo(
|
||||
() => groupFloatingTextsByMember(floatingTexts),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { Spell } from '../game'
|
||||
import type { SpellSlot } from '../components/SpellBars'
|
||||
import { cooldownRemaining } from '../combat/spellCasting'
|
||||
import { useCooldownClock } from './useCooldownClock'
|
||||
|
||||
type UseSpellSlotsOptions = {
|
||||
spells: readonly Spell[]
|
||||
cooldowns: Record<string, number>
|
||||
active: boolean
|
||||
cost: (spell: Spell) => number
|
||||
abilitySlots?: readonly (number | null)[]
|
||||
}
|
||||
|
||||
export function useSpellSlots({
|
||||
spells,
|
||||
cooldowns,
|
||||
active,
|
||||
cost,
|
||||
abilitySlots,
|
||||
}: UseSpellSlotsOptions) {
|
||||
const now = useCooldownClock(cooldowns, active)
|
||||
|
||||
return useMemo<SpellSlot[]>(() => {
|
||||
const slotCount = abilitySlots?.length ?? spells.length
|
||||
return Array.from({ length: slotCount }, (_, slotIndex) => {
|
||||
if (abilitySlots && !abilitySlots[slotIndex]) return null
|
||||
const spell = spells[slotIndex]
|
||||
if (!spell) return null
|
||||
return {
|
||||
...spell,
|
||||
cost: cost(spell),
|
||||
slotIndex,
|
||||
remaining: cooldownRemaining(cooldowns, spell.id, now),
|
||||
}
|
||||
})
|
||||
}, [abilitySlots, cooldowns, cost, now, spells])
|
||||
}
|
||||
+3
-1
@@ -3,7 +3,9 @@
|
||||
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;
|
||||
--body-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace;
|
||||
--pixel-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace;
|
||||
font-family: var(--body-font);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
+262
-46
@@ -37,6 +37,7 @@ export const INPUT_ACTIONS = [
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
'toggleSpeed',
|
||||
'toggleTouchLock',
|
||||
'pause',
|
||||
] as const
|
||||
|
||||
@@ -66,6 +67,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
|
||||
targetParty6: 'Target Party Member 6',
|
||||
toggleTargetGroup: 'Switch Raid Target Group',
|
||||
toggleSpeed: 'Toggle 2x Speed',
|
||||
toggleTouchLock: 'Toggle Combat Touch Lock',
|
||||
pause: 'Pause Menu',
|
||||
}
|
||||
|
||||
@@ -93,6 +95,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
targetParty6: 'F6',
|
||||
toggleTargetGroup: 'Tab',
|
||||
toggleSpeed: 'Backquote',
|
||||
toggleTouchLock: 'F7',
|
||||
pause: 'Escape',
|
||||
},
|
||||
controller: {
|
||||
@@ -115,9 +118,10 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
targetParty3: 'Button15',
|
||||
targetParty4: 'Button13',
|
||||
targetParty5: 'Button4',
|
||||
targetParty6: 'Button10',
|
||||
toggleTargetGroup: 'Button6',
|
||||
targetParty6: 'Button6',
|
||||
toggleTargetGroup: 'Button8',
|
||||
toggleSpeed: 'Button11',
|
||||
toggleTouchLock: 'Button10',
|
||||
pause: 'Button9',
|
||||
},
|
||||
}
|
||||
@@ -126,10 +130,20 @@ 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'
|
||||
const FOCUSABLE_SELECTOR = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
||||
const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
|
||||
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display'
|
||||
const HEADER_CONTENT_SELECTOR = '.app-header'
|
||||
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
|
||||
const GAMEPAD_MENU_POLL_MS = 1000 / 30
|
||||
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
|
||||
const CONTROLLER_REPEAT_INITIAL_MS = 260
|
||||
const CONTROLLER_REPEAT_MS = 85
|
||||
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
|
||||
Button12: 'navigateUp',
|
||||
Button13: 'navigateDown',
|
||||
Button14: 'navigateLeft',
|
||||
Button15: 'navigateRight',
|
||||
}
|
||||
|
||||
let lastControllerFocus: HTMLElement | null = null
|
||||
|
||||
@@ -144,11 +158,13 @@ type InputContextValue = {
|
||||
lastDevice: InputDevice
|
||||
controllerIconStyle: ControllerIconStyle
|
||||
directPartyTargeting: boolean
|
||||
combatTouchLocked: boolean
|
||||
beginCapture: (device: InputDevice, action: InputAction) => void
|
||||
cancelCapture: () => void
|
||||
resetBindings: (device: InputDevice) => void
|
||||
setControllerIconStyle: (style: ControllerIconStyle) => void
|
||||
setDirectPartyTargeting: (enabled: boolean) => void
|
||||
setCombatTouchLocked: (locked: boolean) => void
|
||||
}
|
||||
|
||||
const InputContext = createContext<InputContextValue | null>(null)
|
||||
@@ -187,6 +203,21 @@ function loadBindings(): Record<InputDevice, InputBindings> {
|
||||
if (savedController?.targetParty6 === 'Button11') {
|
||||
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
|
||||
}
|
||||
if (
|
||||
savedController?.targetParty6 === undefined
|
||||
|| savedController.targetParty6 === 'Button10'
|
||||
) {
|
||||
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
|
||||
}
|
||||
if (
|
||||
savedController?.toggleTargetGroup === undefined
|
||||
|| savedController.toggleTargetGroup === 'Button6'
|
||||
) {
|
||||
controller.toggleTargetGroup = DEFAULT_BINDINGS.controller.toggleTargetGroup
|
||||
}
|
||||
if (savedController?.toggleTouchLock === undefined) {
|
||||
controller.toggleTouchLock = DEFAULT_BINDINGS.controller.toggleTouchLock
|
||||
}
|
||||
return {
|
||||
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
||||
controller,
|
||||
@@ -201,15 +232,18 @@ function loadPreferences() {
|
||||
const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as {
|
||||
controllerIconStyle?: ControllerIconStyle
|
||||
directPartyTargeting?: boolean
|
||||
combatTouchLocked?: boolean
|
||||
}
|
||||
return {
|
||||
controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
|
||||
directPartyTargeting: saved.directPartyTargeting ?? false,
|
||||
combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
controllerIconStyle: 'xbox' as ControllerIconStyle,
|
||||
directPartyTargeting: false,
|
||||
combatTouchLocked: Capacitor.isNativePlatform(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,6 +257,7 @@ function bindingGroup(action: InputAction) {
|
||||
if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting'
|
||||
if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting'
|
||||
if (action === 'pause') return 'pause'
|
||||
if (action === 'toggleTouchLock' || action === 'toggleSpeed') return 'system'
|
||||
return 'navigation'
|
||||
}
|
||||
|
||||
@@ -231,6 +266,16 @@ function isVisible(element: HTMLElement) {
|
||||
return element.getClientRects().length > 0
|
||||
}
|
||||
|
||||
function focusableDescendants(scope: ParentNode) {
|
||||
return Array.from(
|
||||
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||
).filter(isVisible)
|
||||
}
|
||||
|
||||
function uniqueElements(elements: HTMLElement[]) {
|
||||
return elements.filter((element, index) => elements.indexOf(element) === index)
|
||||
}
|
||||
|
||||
function focusableElements() {
|
||||
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
|
||||
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
|
||||
@@ -239,10 +284,22 @@ function focusableElements() {
|
||||
'.result-screen, .binding-capture, .dual-startup-prompt',
|
||||
),
|
||||
).find(isVisible)
|
||||
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
|
||||
return Array.from(
|
||||
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||
const mainContent = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(MAIN_CONTENT_SELECTOR),
|
||||
).find(isVisible)
|
||||
const overlay = keyboard ?? pauseMenu ?? dialog
|
||||
if (overlay) return focusableDescendants(overlay)
|
||||
const visibleHeaders = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(HEADER_CONTENT_SELECTOR),
|
||||
).filter(isVisible)
|
||||
if (!mainContent) return visibleHeaders.length > 0 ? [] : focusableDescendants(document)
|
||||
const headerControls = visibleHeaders.flatMap(
|
||||
(header) => focusableDescendants(header),
|
||||
)
|
||||
return uniqueElements([
|
||||
...focusableDescendants(mainContent),
|
||||
...headerControls,
|
||||
])
|
||||
}
|
||||
|
||||
function rememberFocusableControl(element: HTMLElement) {
|
||||
@@ -252,12 +309,20 @@ function rememberFocusableControl(element: HTMLElement) {
|
||||
function focusControl(element: HTMLElement) {
|
||||
rememberFocusableControl(element)
|
||||
element.focus({ preventScroll: true })
|
||||
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
|
||||
function currentFocusableControl(candidates = focusableElements()) {
|
||||
function currentFocusableControl(candidates = focusableElements(), preferControllerFocus = false) {
|
||||
if (
|
||||
preferControllerFocus
|
||||
&& lastControllerFocus
|
||||
&& candidates.includes(lastControllerFocus)
|
||||
&& isVisible(lastControllerFocus)
|
||||
) {
|
||||
return lastControllerFocus
|
||||
}
|
||||
const active = document.activeElement
|
||||
if (active instanceof HTMLElement && candidates.includes(active)) {
|
||||
rememberFocusableControl(active)
|
||||
return active
|
||||
}
|
||||
if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
|
||||
@@ -266,16 +331,33 @@ function currentFocusableControl(candidates = focusableElements()) {
|
||||
return null
|
||||
}
|
||||
|
||||
function rangeDistance(startA: number, endA: number, startB: number, endB: number) {
|
||||
if (endA < startB) return startB - endA
|
||||
if (endB < startA) return startA - endB
|
||||
return 0
|
||||
}
|
||||
|
||||
function changeSelectOption(select: HTMLSelectElement, direction: -1 | 1) {
|
||||
const options = Array.from(select.options).filter((option) => !option.disabled)
|
||||
const currentIndex = options.findIndex((option) => option.index === select.selectedIndex)
|
||||
const nextOption = options[currentIndex + direction]
|
||||
if (!nextOption) return false
|
||||
select.selectedIndex = nextOption.index
|
||||
select.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
return true
|
||||
}
|
||||
|
||||
export function focusFirstControl() {
|
||||
const first = focusableElements()[0]
|
||||
if (first) focusControl(first)
|
||||
return first
|
||||
}
|
||||
|
||||
function moveFocus(action: InputAction) {
|
||||
function moveFocus(action: InputAction, preferControllerFocus = false) {
|
||||
const candidates = focusableElements()
|
||||
if (candidates.length === 0) return
|
||||
const current = currentFocusableControl(candidates)
|
||||
const current = currentFocusableControl(candidates, preferControllerFocus)
|
||||
if (!current) {
|
||||
focusFirstControl()
|
||||
return
|
||||
@@ -286,21 +368,48 @@ function moveFocus(action: InputAction) {
|
||||
const currentY = currentRect.top + currentRect.height / 2
|
||||
const vertical = action === 'navigateUp' || action === 'navigateDown'
|
||||
const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1
|
||||
const currentIndex = candidates.indexOf(current)
|
||||
|
||||
const adjacent = candidates[currentIndex + direction]
|
||||
const fallbackToAdjacent = () => {
|
||||
if (adjacent) focusControl(adjacent)
|
||||
}
|
||||
|
||||
const ranked = candidates
|
||||
.filter((candidate) => candidate !== current)
|
||||
.map((candidate) => {
|
||||
.map((candidate, index) => {
|
||||
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 }
|
||||
const primary = vertical
|
||||
? direction > 0
|
||||
? rect.top - currentRect.bottom
|
||||
: currentRect.top - rect.bottom
|
||||
: direction > 0
|
||||
? rect.left - currentRect.right
|
||||
: currentRect.left - rect.right
|
||||
const secondary = vertical
|
||||
? rangeDistance(currentRect.left, currentRect.right, rect.left, rect.right)
|
||||
: rangeDistance(currentRect.top, currentRect.bottom, rect.top, rect.bottom)
|
||||
return {
|
||||
candidate,
|
||||
index,
|
||||
primary,
|
||||
secondary,
|
||||
score: Math.max(0, primary) + secondary * 2.5 + (vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)) * 0.1,
|
||||
}
|
||||
})
|
||||
.filter(({ primary }) => Math.sign(primary) === direction)
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.filter(({ primary }) => primary >= -4)
|
||||
const sameRowHorizontal = !vertical
|
||||
? ranked.filter(({ secondary }) => secondary === 0)
|
||||
: ranked
|
||||
if (sameRowHorizontal.length === 0) {
|
||||
fallbackToAdjacent()
|
||||
return
|
||||
}
|
||||
sameRowHorizontal.sort((a, b) => a.score - b.score || a.secondary - b.secondary || a.index - b.index)
|
||||
|
||||
const next = ranked[0]?.candidate
|
||||
const next = sameRowHorizontal[0]?.candidate
|
||||
if (!next) return
|
||||
focusControl(next)
|
||||
}
|
||||
@@ -313,6 +422,18 @@ function hasUiOverlay() {
|
||||
).some(isVisible)
|
||||
}
|
||||
|
||||
function hasDedicatedGameNavigation() {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-game-nav-active="true"]'),
|
||||
).some(isVisible)
|
||||
}
|
||||
|
||||
function dispatchGameAction(action: InputAction, device: InputDevice) {
|
||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
||||
detail: { action, device },
|
||||
}))
|
||||
}
|
||||
|
||||
const BUTTON_LABELS: Record<number, string> = {
|
||||
0: 'A / Cross',
|
||||
1: 'B / Circle',
|
||||
@@ -428,6 +549,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const [preferences, setPreferences] = useState(loadPreferences)
|
||||
const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||
const [keyboardShift, setKeyboardShift] = useState(false)
|
||||
const [touchLockMessage, setTouchLockMessage] = useState('')
|
||||
const bindingsRef = useRef(bindings)
|
||||
const preferencesRef = useRef(preferences)
|
||||
const captureRef = useRef(capture)
|
||||
@@ -435,6 +557,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const previousTokensRef = useRef(new Set<string>())
|
||||
const repeatRef = useRef<Record<string, number>>({})
|
||||
const gamepadConnectedRef = useRef(Capacitor.isNativePlatform())
|
||||
const touchLockMessageTimerRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
bindingsRef.current = bindings
|
||||
@@ -481,22 +604,57 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
||||
const uiOverlay = hasUiOverlay()
|
||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||
const controllerUiInput = device === 'controller' && (uiOverlay || !combatActive)
|
||||
const dedicatedNavAction = action.startsWith('navigate') || action === 'confirm' || action === 'back'
|
||||
|
||||
setLastDevice(device)
|
||||
document.documentElement.dataset.inputDevice = device
|
||||
|
||||
if (action.startsWith('navigate')) {
|
||||
if (uiOverlay || !combatActive) moveFocus(action)
|
||||
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
|
||||
dispatchGameAction(action, device)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'toggleTouchLock') {
|
||||
setPreferences((current) => ({
|
||||
...current,
|
||||
combatTouchLocked: !current.combatTouchLocked,
|
||||
}))
|
||||
window.clearTimeout(touchLockMessageTimerRef.current)
|
||||
const nextLocked = !preferencesRef.current.combatTouchLocked
|
||||
setTouchLockMessage(`Combat touch ${nextLocked ? 'locked' : 'unlocked'}`)
|
||||
touchLockMessageTimerRef.current = window.setTimeout(() => {
|
||||
setTouchLockMessage('')
|
||||
}, 1400)
|
||||
} else if (action.startsWith('navigate')) {
|
||||
if (uiOverlay || !combatActive) {
|
||||
const active = currentFocusableControl(focusableElements(), controllerUiInput)
|
||||
if (
|
||||
active instanceof HTMLSelectElement
|
||||
&& (action === 'navigateUp' || action === 'navigateDown')
|
||||
&& changeSelectOption(active, action === 'navigateUp' ? -1 : 1)
|
||||
) {
|
||||
if (controllerUiInput) focusControl(active)
|
||||
return
|
||||
}
|
||||
moveFocus(action, controllerUiInput)
|
||||
}
|
||||
} else if (action === 'confirm') {
|
||||
const active = currentFocusableControl()
|
||||
const active = currentFocusableControl(focusableElements(), controllerUiInput)
|
||||
if (isTextInput(active)) {
|
||||
setKeyboardInput(active)
|
||||
window.requestAnimationFrame(() => focusFirstControl())
|
||||
} else if (active instanceof HTMLSelectElement) {
|
||||
if (controllerUiInput) focusControl(active)
|
||||
const select = active as HTMLSelectElement & { showPicker?: () => void }
|
||||
if (select.showPicker) select.showPicker()
|
||||
else active.click()
|
||||
} else if (
|
||||
active
|
||||
&& active.matches('button:not(:disabled), [role="button"]')
|
||||
&& isVisible(active)
|
||||
) {
|
||||
if (controllerUiInput) focusControl(active)
|
||||
active.click()
|
||||
} else {
|
||||
focusFirstControl()
|
||||
@@ -512,9 +670,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
||||
detail: { action, device },
|
||||
}))
|
||||
dispatchGameAction(action, device)
|
||||
}, [closeKeyboard])
|
||||
|
||||
const dispatchControllerToken = useCallback((token: string, repeat = false) => {
|
||||
@@ -528,12 +684,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
document.querySelector('[data-combat-active="true"]'),
|
||||
)
|
||||
const uiOverlay = hasUiOverlay()
|
||||
const menuDpadActions: Partial<Record<string, InputAction>> = {
|
||||
Button12: 'navigateUp',
|
||||
Button13: 'navigateDown',
|
||||
Button14: 'navigateLeft',
|
||||
Button15: 'navigateRight',
|
||||
}
|
||||
const uiPriority = [
|
||||
'navigateUp',
|
||||
'navigateDown',
|
||||
@@ -551,8 +701,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
'toggleSpeed',
|
||||
'toggleTouchLock',
|
||||
] satisfies InputAction[]
|
||||
const combatPriority = [
|
||||
'toggleTouchLock',
|
||||
'pause',
|
||||
'toggleSpeed',
|
||||
'ability1',
|
||||
@@ -568,18 +720,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
'navigateLeft',
|
||||
'navigateRight',
|
||||
] satisfies InputAction[]
|
||||
const action = menuDpadActions[token] && (!combatActive || uiOverlay)
|
||||
? menuDpadActions[token]
|
||||
const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
|
||||
? DPAD_NAV_ACTIONS[token]
|
||||
: uiOverlay
|
||||
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
|
||||
: combatActive && preferencesRef.current.directPartyTargeting
|
||||
? [...directTargetActions, ...combatPriority].find(
|
||||
(candidate) => bindingsRef.current.controller[candidate] === token,
|
||||
)
|
||||
: combatActive && menuDpadActions[token]
|
||||
? menuDpadActions[token]
|
||||
: !combatActive && menuDpadActions[token]
|
||||
? menuDpadActions[token]
|
||||
: combatActive && DPAD_NAV_ACTIONS[token]
|
||||
? DPAD_NAV_ACTIONS[token]
|
||||
: !combatActive && DPAD_NAV_ACTIONS[token]
|
||||
? DPAD_NAV_ACTIONS[token]
|
||||
: (combatActive ? combatPriority : INPUT_ACTIONS).find(
|
||||
(candidate) => bindingsRef.current.controller[candidate] === token,
|
||||
)
|
||||
@@ -618,15 +770,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
|
||||
if (document.documentElement.dataset.inputDevice !== 'controller') return
|
||||
rememberFocusableControl(target)
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.pointerType !== 'touch') {
|
||||
document.documentElement.dataset.inputDevice = 'pc'
|
||||
}
|
||||
const target = event.target
|
||||
if (!(target instanceof Element)) return
|
||||
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||
if (!control || !isVisible(control)) return
|
||||
rememberFocusableControl(control)
|
||||
if (event.pointerType !== 'touch') rememberFocusableControl(control)
|
||||
}
|
||||
document.addEventListener('focusin', onFocusIn)
|
||||
document.addEventListener('pointerdown', onPointerDown, { capture: true })
|
||||
@@ -645,8 +800,14 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
|
||||
}, [dispatchControllerToken])
|
||||
|
||||
useEffect(() => () => {
|
||||
window.clearTimeout(touchLockMessageTimerRef.current)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let focusFrame = 0
|
||||
const ensureFocus = () => {
|
||||
focusFrame = 0
|
||||
const combatActive = document.querySelector('[data-combat-active="true"]')
|
||||
if (combatActive) return
|
||||
const candidates = focusableElements()
|
||||
@@ -663,22 +824,37 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const scheduleEnsureFocus = () => {
|
||||
if (focusFrame) return
|
||||
focusFrame = window.requestAnimationFrame(ensureFocus)
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
window.requestAnimationFrame(ensureFocus)
|
||||
scheduleEnsureFocus()
|
||||
})
|
||||
observer.observe(document.getElementById('root') ?? document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'],
|
||||
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
window.requestAnimationFrame(ensureFocus)
|
||||
return () => observer.disconnect()
|
||||
scheduleEnsureFocus()
|
||||
return () => {
|
||||
if (focusFrame) window.cancelAnimationFrame(focusFrame)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let timer = 0
|
||||
if (Capacitor.isNativePlatform() && capture?.device !== 'controller') {
|
||||
previousTokensRef.current = new Set()
|
||||
repeatRef.current = {}
|
||||
return undefined
|
||||
}
|
||||
const nextDelay = () => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
return GAMEPAD_COMBAT_POLL_MS
|
||||
}
|
||||
if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) {
|
||||
return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS
|
||||
}
|
||||
@@ -705,11 +881,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const action = INPUT_ACTIONS.find(
|
||||
(candidate) => bindingsRef.current.controller[candidate] === token,
|
||||
)
|
||||
const canRepeat = action?.startsWith('navigate') ?? false
|
||||
const canRepeat = Boolean(action?.startsWith('navigate') || DPAD_NAV_ACTIONS[token])
|
||||
const nextRepeat = repeatRef.current[token] ?? 0
|
||||
if (pressed || (canRepeat && time >= nextRepeat)) {
|
||||
dispatchControllerToken(token, !pressed)
|
||||
repeatRef.current[token] = time + (pressed ? 360 : 125)
|
||||
repeatRef.current[token] = time + (pressed ? CONTROLLER_REPEAT_INITIAL_MS : CONTROLLER_REPEAT_MS)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -735,7 +911,39 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
window.removeEventListener('gamepadconnected', onGamepadConnected)
|
||||
window.removeEventListener('gamepaddisconnected', onGamepadDisconnected)
|
||||
}
|
||||
}, [assignBinding, dispatchControllerToken])
|
||||
}, [assignBinding, capture, dispatchControllerToken])
|
||||
|
||||
useEffect(() => {
|
||||
const shouldBlockTouch = () => (
|
||||
Capacitor.isNativePlatform()
|
||||
&& preferencesRef.current.combatTouchLocked
|
||||
&& isCombatActive()
|
||||
&& !hasUiOverlay()
|
||||
)
|
||||
const blockTouch = (event: Event) => {
|
||||
if (
|
||||
typeof PointerEvent !== 'undefined'
|
||||
&& event instanceof PointerEvent
|
||||
&& event.pointerType !== 'touch'
|
||||
) return
|
||||
if (!shouldBlockTouch()) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
const options = { capture: true, passive: false }
|
||||
document.addEventListener('pointerdown', blockTouch, options)
|
||||
document.addEventListener('pointerup', blockTouch, options)
|
||||
document.addEventListener('touchstart', blockTouch, options)
|
||||
document.addEventListener('touchmove', blockTouch, options)
|
||||
document.addEventListener('touchend', blockTouch, options)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', blockTouch, options)
|
||||
document.removeEventListener('pointerup', blockTouch, options)
|
||||
document.removeEventListener('touchstart', blockTouch, options)
|
||||
document.removeEventListener('touchmove', blockTouch, options)
|
||||
document.removeEventListener('touchend', blockTouch, options)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const contextValue = useMemo<InputContextValue>(() => ({
|
||||
bindings,
|
||||
@@ -743,6 +951,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
lastDevice,
|
||||
controllerIconStyle: preferences.controllerIconStyle,
|
||||
directPartyTargeting: preferences.directPartyTargeting,
|
||||
combatTouchLocked: preferences.combatTouchLocked,
|
||||
beginCapture: (device, action) => setCapture({ device, action }),
|
||||
cancelCapture: () => setCapture(null),
|
||||
resetBindings: (device) => setBindings((current) => ({
|
||||
@@ -757,6 +966,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
...current,
|
||||
directPartyTargeting,
|
||||
})),
|
||||
setCombatTouchLocked: (combatTouchLocked) => setPreferences((current) => ({
|
||||
...current,
|
||||
combatTouchLocked,
|
||||
})),
|
||||
}), [bindings, capture, lastDevice, preferences])
|
||||
|
||||
function typeKeyboardKey(key: string) {
|
||||
@@ -821,6 +1034,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{touchLockMessage && (
|
||||
<div className="combat-touch-lock-status" aria-live="polite">
|
||||
{touchLockMessage}
|
||||
</div>
|
||||
)}
|
||||
</InputContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -852,7 +1070,5 @@ export function dispatchExternalGameAction(
|
||||
action: InputAction,
|
||||
device: InputDevice,
|
||||
) {
|
||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
||||
detail: { action, device },
|
||||
}))
|
||||
dispatchGameAction(action, device)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt }
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
{new URLSearchParams(window.location.search).get('display') === 'bottom' ? (
|
||||
<InputProvider>
|
||||
<DualScreenBottomDisplay />
|
||||
</InputProvider>
|
||||
) : (
|
||||
<DualScreenProvider>
|
||||
<DualScreenStartupPrompt />
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
|
||||
export const bundledCatalogHash = '2bc6b08d7c902e8f47d74cd0b8c43f5962c1a583cb1f8b42f1ece2b512ad8991'
|
||||
|
||||
@@ -6905,5 +6905,11 @@
|
||||
"full_run": []
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"hunterStats": {
|
||||
"bossKills": {},
|
||||
"bossPets": {},
|
||||
"pvpMatchesPlayed": 0,
|
||||
"pvpMatchesWon": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,14 @@ export type LootRollItem = Omit<Item, 'quantity' | 'equipped'> & {
|
||||
quantityAfter: number
|
||||
}
|
||||
|
||||
export type PetAward = {
|
||||
encounterId: number
|
||||
petName: string
|
||||
quantity: number
|
||||
duplicate: boolean
|
||||
quantityAfter: number
|
||||
}
|
||||
|
||||
export type LootRoll = {
|
||||
encounterId: number
|
||||
encounterName: string
|
||||
@@ -157,6 +165,7 @@ export type LootRoll = {
|
||||
awarded: boolean
|
||||
duplicate: boolean
|
||||
quantityAfter: number
|
||||
petAwarded?: PetAward | null
|
||||
}
|
||||
|
||||
export type Dungeon = {
|
||||
@@ -232,6 +241,12 @@ export type CharacterProfile = {
|
||||
craftingRecipes: CraftingRecipe[]
|
||||
gearUpgradePaths: GearUpgradePath[]
|
||||
dungeons: Dungeon[]
|
||||
hunterStats: {
|
||||
bossKills: Record<string, number>
|
||||
bossPets: Record<string, number>
|
||||
pvpMatchesPlayed: number
|
||||
pvpMatchesWon: number
|
||||
}
|
||||
}
|
||||
|
||||
export type Account = {
|
||||
@@ -280,6 +295,7 @@ export type DungeonReward = {
|
||||
glyph: string
|
||||
}>
|
||||
bonusItem: BonusItem | null
|
||||
petAwarded?: PetAward | null
|
||||
profile: CharacterProfile
|
||||
}
|
||||
|
||||
@@ -365,6 +381,20 @@ export async function completeRoguelike(
|
||||
)
|
||||
}
|
||||
|
||||
export async function recordBossKill(
|
||||
encounterId: number,
|
||||
options?: { petVariant?: 'normal' | 'purple' },
|
||||
): Promise<{
|
||||
profile: CharacterProfile
|
||||
petAwarded: PetAward | null
|
||||
}> {
|
||||
return activeGameRepository().recordBossKill(encounterId, options)
|
||||
}
|
||||
|
||||
export async function recordPvpMatch(won: boolean): Promise<CharacterProfile> {
|
||||
return activeGameRepository().recordPvpMatch(won)
|
||||
}
|
||||
|
||||
export async function allocateTalent(talentId: number): Promise<CharacterProfile> {
|
||||
return activeGameRepository().allocateTalent(talentId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user