Compare commits

...
9 Commits
Author SHA1 Message Date
Warren H c6e2c61d0a Android build v1.1.11 2026-07-02 23:03:52 -04:00
Warren H f00ad8655b Android build v1.1.10 2026-07-02 22:42:48 -04:00
Warren H 6560a049f2 Android build v1.1.9 2026-07-02 21:43:04 -04:00
Warren H 89bc1b6ab8 Android build v1.1.8 2026-07-02 14:11:18 -04:00
Warren H 5b8c32dc41 Android build v1.1.7 2026-06-30 14:23:45 -04:00
Warren H df6306fe7a Android build v1.1.6 2026-06-30 13:59:36 -04:00
Warren H f6d408f594 Android build v1.1.5 2026-06-30 13:30:33 -04:00
Warren H 1797154726 Android build v1.1.4 2026-06-29 23:06:37 -04:00
Warren H bbcc67a54a Android build v1.1.3 2026-06-29 22:55:53 -04:00
46 changed files with 3752 additions and 451 deletions
+27 -1
View File
@@ -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.
+2 -2
View File
@@ -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) {
bridge.getWebView().clearCache(true);
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,24 +147,104 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad()) return true;
String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(
() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
);
if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
dispatchNativeControllerToken(token, repeat);
}
return true;
}
private boolean shouldThrottleDpad() {
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (
bridge == null
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|| !isControllerMotionEvent(event)
) {
return super.dispatchGenericMotionEvent(event);
}
Set<String> currentTokens = new HashSet<>();
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();
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true;
lastDpadDispatchAt = now;
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 + "}}));";
bridge.getWebView().post(
() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
);
}
private boolean shouldThrottleDpad(String token) {
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt[buttonIndex] < DPAD_THROTTLE_MS) return true;
lastDpadDispatchAt[buttonIndex] = now;
return false;
}
@@ -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);
+14
View File
@@ -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,
+64
View File
@@ -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",
+3
View File
@@ -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.
+502
View File
@@ -0,0 +1,502 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import queue
import re
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from getpass import getpass
from pathlib import Path
from typing import Callable
try:
import tkinter as tk
from tkinter import messagebox, ttk
except ImportError:
tk = None
messagebox = None
ttk = None
REPO_ROOT = Path(__file__).resolve().parents[1]
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
DEFAULT_JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
DEFAULT_API_BASE_URL = "https://iwanttoheal.phenomrom.com"
DEFAULT_GITEA_URL = "https://git.whoagland.com"
DEFAULT_GITEA_OWNER = "phenom"
DEFAULT_GITEA_REPO = "i-want-to-heal"
DEFAULT_BRANCH = "main"
DEFAULT_TRUENAS_PATH = "/mnt/usbssds/apps/iwanttoheal/app"
DEFAULT_DB_PATH = "/mnt/usbssds/apps/iwanttoheal/data/game.db"
class ReleaseError(RuntimeError):
pass
@dataclass(frozen=True)
class ReleaseConfig:
version: str
token: str
api_base_url: str
java_home: str
gitea_url: str
gitea_owner: str
gitea_repo: str
branch: str
truenas_path: str
truenas_db_path: str
restart_command: str
run_build: bool
run_commit_push: bool
run_release: bool
run_truenas_pull: bool
run_truenas_backup: bool
run_restart: bool
def read_current_version() -> str:
text = GRADLE_FILE.read_text()
match = re.search(r'versionName\s+"([^"]+)"', text)
return match.group(1) if match else ""
def run_command(
args: list[str],
*,
cwd: Path,
env: dict[str, str] | None,
log: Callable[[str], None],
) -> None:
log(f"$ {' '.join(args)}")
proc = subprocess.Popen(
args,
cwd=cwd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
assert proc.stdout is not None
for line in proc.stdout:
log(line.rstrip("\n"))
code = proc.wait()
if code != 0:
raise ReleaseError(f"Command failed ({code}): {' '.join(args)}")
def update_gradle_version(version: str, log: Callable[[str], None]) -> int:
text = GRADLE_FILE.read_text()
code_match = re.search(r"versionCode\s+(\d+)", text)
if not code_match:
raise ReleaseError(f"Could not find versionCode in {GRADLE_FILE}")
next_code = int(code_match.group(1)) + 1
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
GRADLE_FILE.write_text(text)
log(f"Android version set: versionName {version}, versionCode {next_code}")
return next_code
def build_apk(config: ReleaseConfig, log: Callable[[str], None]) -> Path:
update_gradle_version(config.version, log)
env = os.environ.copy()
env["JAVA_HOME"] = config.java_home
env["PATH"] = f"{config.java_home}/bin:{env.get('PATH', '')}"
env["VITE_API_BASE_URL"] = config.api_base_url
run_command(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env, log=log)
run_command(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env, log=log)
apk_source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
if not apk_source.exists():
raise ReleaseError(f"APK not found: {apk_source}")
apk_target = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
apk_target.write_bytes(apk_source.read_bytes())
log(f"APK copied: {apk_target.name} ({apk_target.stat().st_size:,} bytes)")
return apk_target
def commit_and_push(config: ReleaseConfig, log: Callable[[str], None]) -> None:
message = f"Android build v{config.version}"
run_command(["git", "add", "."], cwd=REPO_ROOT, env=None, log=log)
run_command(["git", "commit", "-m", message], cwd=REPO_ROOT, env=None, log=log)
run_command(["git", "push", "origin", config.branch], cwd=REPO_ROOT, env=None, log=log)
def gitea_request(
config: ReleaseConfig,
method: str,
path: str,
*,
body: bytes | None = None,
content_type: str | None = None,
) -> tuple[int, bytes]:
url = config.gitea_url.rstrip("/") + path
headers = {"Authorization": f"token {config.token}"}
if content_type:
headers["Content-Type"] = content_type
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as exc:
data = exc.read()
return exc.code, data
def parse_json_response(status: int, data: bytes, action: str) -> dict:
try:
parsed = json.loads(data.decode("utf-8") or "{}")
except json.JSONDecodeError as exc:
raise ReleaseError(f"{action} failed ({status}): {data.decode('utf-8', 'replace')}") from exc
if status >= 400:
raise ReleaseError(f"{action} failed ({status}): {parsed}")
if not isinstance(parsed, dict):
raise ReleaseError(f"{action} returned unexpected JSON: {parsed}")
return parsed
def find_or_create_release(config: ReleaseConfig, log: Callable[[str], None]) -> int:
repo_path = f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
tag = f"v{config.version}"
tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
status, data = gitea_request(config, "GET", tag_path)
if status == 200:
release = parse_json_response(status, data, "Find release")
release_id = release.get("id")
if not release_id:
raise ReleaseError(f"Existing release missing id: {release}")
log(f"Gitea release exists: {tag} (id {release_id})")
return int(release_id)
if status != 404:
parse_json_response(status, data, "Find release")
payload = {
"tag_name": tag,
"target_commitish": config.branch,
"name": tag,
"body": f"I Want to Heal Android build v{config.version}",
"draft": False,
"prerelease": False,
}
status, data = gitea_request(
config,
"POST",
repo_path + "/releases",
body=json.dumps(payload).encode("utf-8"),
content_type="application/json",
)
release = parse_json_response(status, data, "Create release")
release_id = release.get("id")
if not release_id:
raise ReleaseError(f"Created release missing id: {release}")
log(f"Gitea release created: {tag} (id {release_id})")
return int(release_id)
def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> None:
apk_path = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
if not apk_path.exists():
raise ReleaseError(f"APK not found for release upload: {apk_path}")
release_id = find_or_create_release(config, log)
boundary = f"----iwanttoheal{int(time.time() * 1000)}"
header = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
body = header + apk_path.read_bytes() + footer
asset_name = urllib.parse.quote(apk_path.name)
path = (
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
f"/releases/{release_id}/assets?name={asset_name}"
)
status, data = gitea_request(
config,
"POST",
path,
body=body,
content_type=f"multipart/form-data; boundary={boundary}",
)
if status == 409:
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
parse_json_response(status, data, "Upload release asset")
log(f"Gitea asset uploaded: {apk_path.name}")
def update_truenas(config: ReleaseConfig, log: Callable[[str], None]) -> None:
app_path = Path(config.truenas_path)
if not app_path.exists():
raise ReleaseError(f"TrueNAS path not found: {app_path}")
run_command(["git", "pull"], cwd=app_path, env=None, log=log)
if config.run_truenas_backup:
db_path = Path(config.truenas_db_path)
if not db_path.exists():
raise ReleaseError(f"TrueNAS database not found: {db_path}")
stamp = time.strftime("%Y%m%d-%H%M%S")
backup_path = db_path.with_name(f"game-before-update-{stamp}.db")
backup_path.write_bytes(db_path.read_bytes())
log(f"TrueNAS database backup copied: {backup_path}")
if config.run_restart:
if not config.restart_command.strip():
raise ReleaseError("Restart command is empty")
run_command(["sh", "-lc", config.restart_command], cwd=app_path, env=None, log=log)
def validate_config(config: ReleaseConfig) -> None:
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", config.version):
raise ReleaseError("Version must look like 1.1.2")
if config.run_release and not config.token:
raise ReleaseError("Gitea token required for release step")
if config.run_restart and not config.run_truenas_pull:
raise ReleaseError("Restart needs TrueNAS update step enabled")
def prompt_text(label: str, default: str = "", *, secret: bool = False) -> str:
prompt = f"{label}"
if default:
prompt += f" [{default}]"
prompt += ": "
try:
value = getpass(prompt) if secret else input(prompt)
except EOFError:
value = ""
return value.strip() or default
def prompt_bool(label: str, default: bool) -> bool:
suffix = "Y/n" if default else "y/N"
try:
value = input(f"{label} [{suffix}]: ").strip().lower()
except EOFError:
value = ""
if not value:
return default
return value in {"y", "yes", "true", "1"}
def run_release_steps(config: ReleaseConfig, log: Callable[[str], None]) -> None:
if config.run_build:
build_apk(config, log)
if config.run_commit_push:
commit_and_push(config, log)
if config.run_release:
upload_release_asset(config, log)
if config.run_truenas_pull:
update_truenas(config, log)
def cli_main() -> int:
print("Tkinter not available. CLI mode.")
current_version = read_current_version()
config = ReleaseConfig(
version=prompt_text("Version", current_version),
token=prompt_text("Gitea token", secret=True),
api_base_url=prompt_text("API base URL", DEFAULT_API_BASE_URL),
java_home=prompt_text("JAVA_HOME", DEFAULT_JAVA_HOME),
gitea_url=prompt_text("Gitea URL", DEFAULT_GITEA_URL),
gitea_owner=prompt_text("Gitea owner", DEFAULT_GITEA_OWNER),
gitea_repo=prompt_text("Gitea repo", DEFAULT_GITEA_REPO),
branch=prompt_text("Git branch", DEFAULT_BRANCH),
truenas_path=prompt_text("TrueNAS app path", DEFAULT_TRUENAS_PATH),
truenas_db_path=prompt_text("TrueNAS DB path", DEFAULT_DB_PATH),
restart_command=prompt_text("Restart command"),
run_build=prompt_bool("Build APK", True),
run_commit_push=prompt_bool("Commit + push", True),
run_release=prompt_bool("Create Gitea release", True),
run_truenas_pull=prompt_bool("TrueNAS pull", True),
run_truenas_backup=prompt_bool("Backup DB", True),
run_restart=prompt_bool("Restart", False),
)
validate_config(config)
run_release_steps(config, print)
print("Release steps complete")
return 0
if tk is not None and ttk is not None and messagebox is not None:
class ReleaseApp(ttk.Frame):
def __init__(self, root: tk.Tk) -> None:
super().__init__(root, padding=12)
self.root = root
self.log_queue: queue.Queue[tuple[str, str]] = queue.Queue()
self.worker: threading.Thread | None = None
self.vars: dict[str, tk.Variable] = {}
self.grid(sticky="nsew")
root.title("I Want to Heal Release Tool")
root.geometry("920x720")
root.columnconfigure(0, weight=1)
root.rowconfigure(0, weight=1)
self.columnconfigure(1, weight=1)
self.rowconfigure(13, weight=1)
self._build_form()
self.after(100, self._drain_log_queue)
def _var(self, name: str, value: object = "") -> tk.Variable:
if isinstance(value, bool):
var: tk.Variable = tk.BooleanVar(value=value)
else:
var = tk.StringVar(value=str(value))
self.vars[name] = var
return var
def _build_form(self) -> None:
current_version = read_current_version()
fields = [
("Version", "version", current_version),
("Gitea token", "token", ""),
("API base URL", "api_base_url", DEFAULT_API_BASE_URL),
("JAVA_HOME", "java_home", DEFAULT_JAVA_HOME),
("Gitea URL", "gitea_url", DEFAULT_GITEA_URL),
("Gitea owner", "gitea_owner", DEFAULT_GITEA_OWNER),
("Gitea repo", "gitea_repo", DEFAULT_GITEA_REPO),
("Git branch", "branch", DEFAULT_BRANCH),
("TrueNAS app path", "truenas_path", DEFAULT_TRUENAS_PATH),
("TrueNAS DB path", "truenas_db_path", DEFAULT_DB_PATH),
("Restart command", "restart_command", ""),
]
for row, (label, name, value) in enumerate(fields):
ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=3)
entry = ttk.Entry(self, textvariable=self._var(name, value), show="*" if name == "token" else "")
entry.grid(row=row, column=1, sticky="ew", pady=3)
checks = ttk.Frame(self)
checks.grid(row=11, column=0, columnspan=2, sticky="ew", pady=(10, 4))
check_data = [
("Build APK", "run_build", True),
("Commit + push", "run_commit_push", True),
("Create Gitea release", "run_release", True),
("TrueNAS pull", "run_truenas_pull", True),
("Backup DB", "run_truenas_backup", True),
("Restart", "run_restart", False),
]
for index, (text, name, value) in enumerate(check_data):
ttk.Checkbutton(checks, text=text, variable=self._var(name, value)).grid(
row=0, column=index, padx=(0, 14), sticky="w"
)
buttons = ttk.Frame(self)
buttons.grid(row=12, column=0, columnspan=2, sticky="ew", pady=(6, 8))
self.run_button = ttk.Button(buttons, text="Run Selected Steps", command=self.run_release)
self.run_button.pack(side="left")
ttk.Button(buttons, text="Start Admin Server", command=self.start_admin).pack(side="left", padx=8)
self.log_text = tk.Text(self, height=18, wrap="word")
self.log_text.grid(row=13, column=0, columnspan=2, sticky="nsew")
scroll = ttk.Scrollbar(self, command=self.log_text.yview)
scroll.grid(row=13, column=2, sticky="ns")
self.log_text.configure(yscrollcommand=scroll.set)
def config_from_form(self) -> ReleaseConfig:
get = lambda name: str(self.vars[name].get()).strip()
get_bool = lambda name: bool(self.vars[name].get())
return ReleaseConfig(
version=get("version"),
token=get("token"),
api_base_url=get("api_base_url"),
java_home=get("java_home"),
gitea_url=get("gitea_url"),
gitea_owner=get("gitea_owner"),
gitea_repo=get("gitea_repo"),
branch=get("branch"),
truenas_path=get("truenas_path"),
truenas_db_path=get("truenas_db_path"),
restart_command=get("restart_command"),
run_build=get_bool("run_build"),
run_commit_push=get_bool("run_commit_push"),
run_release=get_bool("run_release"),
run_truenas_pull=get_bool("run_truenas_pull"),
run_truenas_backup=get_bool("run_truenas_backup"),
run_restart=get_bool("run_restart"),
)
def log(self, message: str) -> None:
self.log_queue.put(("log", message))
def _drain_log_queue(self) -> None:
try:
while True:
kind, message = self.log_queue.get_nowait()
if kind == "done":
self.run_button.configure(state="normal")
if message:
messagebox.showinfo("Release tool", message)
elif kind == "error":
self.run_button.configure(state="normal")
messagebox.showerror("Release failed", message)
else:
self.log_text.insert("end", message + "\n")
self.log_text.see("end")
except queue.Empty:
pass
self.after(100, self._drain_log_queue)
def run_release(self) -> None:
if self.worker and self.worker.is_alive():
return
try:
config = self.config_from_form()
validate_config(config)
except ReleaseError as exc:
messagebox.showerror("Invalid settings", str(exc))
return
self.run_button.configure(state="disabled")
self.log_text.delete("1.0", "end")
self.worker = threading.Thread(target=self._run_release_worker, args=(config,), daemon=True)
self.worker.start()
def _run_release_worker(self, config: ReleaseConfig) -> None:
try:
run_release_steps(config, self.log)
self.log_queue.put(("done", "Release steps complete"))
except Exception as exc:
self.log_queue.put(("error", str(exc)))
def start_admin(self) -> None:
if self.worker and self.worker.is_alive():
messagebox.showerror("Busy", "Release steps are still running")
return
self.log_text.delete("1.0", "end")
self.run_button.configure(state="disabled")
self.worker = threading.Thread(target=self._start_admin_worker, daemon=True)
self.worker.start()
def _start_admin_worker(self) -> None:
try:
self.log("Admin URL: http://127.0.0.1:4174")
run_command(["npm", "run", "admin:start"], cwd=REPO_ROOT, env=None, log=self.log)
self.log_queue.put(("done", "Admin server stopped"))
except Exception as exc:
self.log_queue.put(("error", str(exc)))
def main() -> int:
if not GRADLE_FILE.exists():
print(f"Missing Gradle file: {GRADLE_FILE}", file=sys.stderr)
return 1
if tk is None:
return cli_main()
root = tk.Tk()
ReleaseApp(root)
root.mainloop()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
API_BASE_URL = "https://iwanttoheal.phenomrom.com"
GITEA_URL = "https://git.whoagland.com"
GITEA_OWNER = "phenom"
GITEA_REPO = "i-want-to-heal"
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
BRANCH = "main"
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app")
PLAYER_DATA_TABLES = [
"encounter_loot_roll_items",
"encounter_loot_rolls",
"dungeon_runs",
"character_pvp_stats",
"character_boss_stats",
"character_inventory",
"character_talents",
"character_ability_slots",
"action_encounter_loot_rolls",
"action_dungeon_runs",
"action_character_inventory",
"action_gear_items",
"action_characters",
"sessions",
"characters",
"accounts",
]
def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
print(f"$ {' '.join(args)}", flush=True)
subprocess.run(args, cwd=cwd, env=env, check=True)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build and publish an Android release.")
parser.add_argument(
"--reset-player-data",
action="store_true",
help="After release, back up the database and delete all accounts, characters, sessions, runs, loot, inventory, and PvP stats.",
)
parser.add_argument(
"--reset-db",
type=Path,
help="SQLite game.db path to reset. Defaults to mounted TrueNAS data/game.db when present, otherwise local data/game.db.",
)
parser.add_argument(
"--yes-reset",
action="store_true",
help="Skip the RESET confirmation when used with --reset-player-data.",
)
args = parser.parse_args(argv)
if args.yes_reset and not args.reset_player_data:
parser.error("--yes-reset requires --reset-player-data")
return args
def prompt_version() -> str:
current = GRADLE_FILE.read_text()
match = re.search(r'versionName\s+"([^"]+)"', current)
current_version = match.group(1) if match else ""
suffix = f" [{current_version}]" if current_version else ""
version = input(f"Version{suffix}: ").strip() or current_version
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
raise SystemExit("Version must look like 1.1.2")
return version
def update_gradle_version(version: str) -> None:
text = GRADLE_FILE.read_text()
code_match = re.search(r"versionCode\s+(\d+)", text)
if not code_match:
raise SystemExit(f"Could not find versionCode in {GRADLE_FILE}")
next_code = int(code_match.group(1)) + 1
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
GRADLE_FILE.write_text(text)
print(f"Android version: {version}, versionCode: {next_code}")
def build_apk(version: str) -> Path:
update_gradle_version(version)
env = os.environ.copy()
env["JAVA_HOME"] = JAVA_HOME
env["PATH"] = f"{JAVA_HOME}/bin:{env.get('PATH', '')}"
env["VITE_API_BASE_URL"] = API_BASE_URL
run(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env)
run(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env)
source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
shutil.copy2(source, apk)
print(f"APK: {apk}")
return apk
def commit_and_push(version: str) -> None:
run(["git", "add", "."], cwd=REPO_ROOT)
run(["git", "commit", "-m", f"Android build v{version}"], cwd=REPO_ROOT)
run(["git", "push", "origin", BRANCH], cwd=REPO_ROOT)
def request_json(method: str, path: str, token: str, data: dict | None = None) -> dict:
body = json.dumps(data).encode("utf-8") if data is not None else None
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
def upload_asset(path: str, token: str, apk: Path) -> dict:
boundary = "----iwanttoheal-release-boundary"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n'
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
body += apk.read_bytes()
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
headers = {
"Authorization": f"token {token}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
}
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc
def create_gitea_release(version: str, apk: Path) -> None:
token = GITEA_TOKEN.strip()
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
release = request_json(
"POST",
repo_path + "/releases",
token,
{
"tag_name": f"v{version}",
"target_commitish": BRANCH,
"name": f"v{version}",
"body": f"I Want to Heal Android build v{version}",
"draft": False,
"prerelease": False,
},
)
release_id = release.get("id")
if not release_id:
raise SystemExit(f"Gitea release missing id: {release}")
asset_name = urllib.parse.quote(apk.name)
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
print(f"Gitea release uploaded: v{version}")
def update_truenas() -> None:
if TRUENAS_PATH.exists():
run(["git", "pull"], cwd=TRUENAS_PATH)
print("Restart app in TrueNAS UI.")
return
print("TrueNAS path not mounted here. Run on TrueNAS:")
print(f"cd {TRUENAS_PATH}")
print("git pull")
print("Then restart app in TrueNAS UI.")
def default_reset_database_path() -> Path:
truenas_database = TRUENAS_PATH / "data" / "game.db"
if truenas_database.exists():
return truenas_database
return REPO_ROOT / "data" / "game.db"
def table_exists(database: sqlite3.Connection, table: str) -> bool:
return database.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
).fetchone() is not None
def count_rows(database: sqlite3.Connection, table: str) -> int:
if not table_exists(database, table):
return 0
return int(database.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0])
def backup_database(database_path: Path) -> Path:
backup_dir = database_path.parent / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = backup_dir / f"{database_path.stem}-pre-player-reset-{timestamp}{database_path.suffix}"
source = sqlite3.connect(database_path)
try:
destination = sqlite3.connect(backup_path)
try:
source.backup(destination)
finally:
destination.close()
finally:
source.close()
return backup_path
def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
database_path = database_path.expanduser().resolve()
if not database_path.exists():
raise SystemExit(f"Database not found: {database_path}")
if not assume_yes:
print(f"Reset target: {database_path}")
confirmation = input("Type RESET to delete all accounts/characters/player data: ").strip()
if confirmation != "RESET":
raise SystemExit("Player data reset cancelled.")
backup_path = backup_database(database_path)
database = sqlite3.connect(database_path)
try:
before = {
"accounts": count_rows(database, "accounts"),
"characters": count_rows(database, "characters"),
"action_characters": count_rows(database, "action_characters"),
"dungeon_runs": count_rows(database, "dungeon_runs"),
"encounter_loot_rolls": count_rows(database, "encounter_loot_rolls"),
}
database.execute("PRAGMA foreign_keys = ON")
database.execute("BEGIN")
try:
for table in PLAYER_DATA_TABLES:
if table_exists(database, table):
database.execute(f'DELETE FROM "{table}"')
database.execute("COMMIT")
except Exception:
database.execute("ROLLBACK")
raise
after_accounts = count_rows(database, "accounts")
after_characters = count_rows(database, "characters")
if after_accounts or after_characters:
raise SystemExit(
f"Player data reset incomplete: {after_accounts} accounts, {after_characters} characters remain."
)
print(f"Database backup: {backup_path}")
print(
"Player data reset: "
f"{before['accounts']} accounts, "
f"{before['characters']} characters, "
f"{before['action_characters']} action characters, "
f"{before['dungeon_runs']} dungeon runs, "
f"{before['encounter_loot_rolls']} loot rolls removed."
)
finally:
database.close()
def main(argv: list[str] | None = None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
os.chdir(REPO_ROOT)
version = prompt_version()
apk = build_apk(version)
commit_and_push(version)
create_gitea_release(version, apk)
update_truenas()
if args.reset_player_data:
reset_player_data(args.reset_db or default_reset_database_path(), assume_yes=args.yes_reset)
print("Done.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+325 -58
View File
@@ -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,37 +679,40 @@ 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' : ''}`}>
<header className="topbar app-header">
<button
className="brand-button"
onClick={() => setScreen('menu')}
type="button"
>
<strong>Menu</strong>
</button>
<div className="character-summary">
<strong>{profile.character.name}</strong>
<small>Level {profile.character.level}</small>
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
<div className="header-xp" title={`${profile.character.experience} total experience`}>
<span style={{ width: `${experiencePercent}%` }} />
</div>
<button className="logout-button" onClick={signOut} type="button">
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
<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"
onClick={() => setScreen('menu')}
type="button"
>
<strong>Menu</strong>
</button>
</div>
</header>
<div className="character-summary">
<strong>{profile.character.name}</strong>
<small>Level {profile.character.level}</small>
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
<div className="header-xp" title={`${profile.character.experience} total experience`}>
<span style={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,27 +734,26 @@ function App() {
</button>
</div>
)}
{MENU_ITEMS.map((item) => (
<button
className="menu-card"
key={item.screen}
onClick={() => {
if (item.screen === 'pvp') {
setRoguelikeVariant('pvp')
setScreen('roguelike')
return
}
setScreen(item.screen)
}}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.label}</strong>
<small>{item.description}</small>
</div>
</button>
))}
{MENU_ITEMS.map((item, index) => {
const homeIndex = index + homeMenuOffset
return (
<button
className={`menu-card ${homeActiveIndex === homeIndex ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={homeActiveIndex === homeIndex ? 'true' : undefined}
key={item.screen}
onClick={() => openHomeMenuIndex(homeIndex)}
onPointerDown={() => setHomeSelectedIndex(homeIndex)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.label}</strong>
<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')} />
)}
+4 -1
View File
@@ -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>
<AdminScreen onBack={() => window.close()} />
<InputProvider>
<AdminScreen onBack={() => window.close()} />
</InputProvider>
</StrictMode>,
)
+60 -2
View File
@@ -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,
}
}
+62 -22
View File
@@ -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,
@@ -1230,9 +1270,9 @@ export function CombatScreen({
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
return (
<main
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
data-combat-active={paused ? 'false' : 'true'}
<main
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
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>
+240
View File
@@ -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>
)
}
+3 -2
View File
@@ -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">
+54 -16
View File
@@ -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}
+48 -17
View File
@@ -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}
+7 -6
View File
@@ -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>
))}
+30 -25
View File
@@ -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,33 +91,28 @@ 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 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' },
{ key: 'input', label: 'Input' },
{ key: 'bindings', label: 'Bindings' },
] as const).map((tab) => (
<button
aria-selected={settingsTab === tab.key}
className={settingsTab === tab.key ? 'selected' : ''}
key={tab.key}
onClick={() => setSettingsTab(tab.key)}
role="tab"
type="button"
>
{tab.label}
</button>
))}
</nav>
</div>
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
{([
{ key: 'display', label: 'Display' },
{ key: 'input', label: 'Input' },
{ key: 'bindings', label: 'Bindings' },
] as const).map((tab) => (
<button
aria-selected={settingsTab === tab.key}
className={settingsTab === tab.key ? 'selected' : ''}
key={tab.key}
onClick={() => setSettingsTab(tab.key)}
role="tab"
type="button"
>
{tab.label}
</button>
))}
</nav>
{settingsTab === 'display' && (
<section className="dual-screen-settings settings-tab-panel">
<div>
@@ -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) => (
+9 -1
View File
@@ -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}
+16
View File
@@ -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`,
}
}
+93 -39
View File
@@ -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 (
<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">
<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>
<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>
)
})}
<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}%` }} />
{!state.opponentParty && (
<section className="dual-top-spell-strip">
{spellButtons}
<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>
</div>
</section>
</section>
)}
</div>
)
+151 -10
View File
@@ -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')
+36
View File
@@ -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
}
+40 -18
View File
@@ -15,41 +15,63 @@ 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 clearCleanupTimer = useCallback(() => {
if (cleanupTimerRef.current === null) return
window.clearTimeout(cleanupTimerRef.current)
cleanupTimerRef.current = null
}, [])
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
cleanupTimerRef.current = window.setTimeout(() => {
cleanupTimerRef.current = null
const now = performance.now()
let removed = false
for (const [id, expiresAt] of expirationsRef.current) {
if (expiresAt > now) continue
expirationsRef.current.delete(id)
removed = true
}
if (removed) {
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
}
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))
}, [durationMs])
scheduleCleanup()
}, [durationMs, scheduleCleanup])
useEffect(() => {
const timer = window.setInterval(() => {
const now = performance.now()
let removed = false
for (const [id, expiresAt] of expirationsRef.current) {
if (expiresAt > now) continue
expirationsRef.current.delete(id)
removed = true
}
if (!removed) return
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
}, FLOATING_TEXT_CLEANUP_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [])
useEffect(() => () => clearCleanupTimer(), [clearCleanupTimer])
const floatingTextsByMember = useMemo(
() => groupFloatingTextsByMember(floatingTexts),
+38
View File
@@ -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
View File
@@ -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;
}
+263 -47
View File
@@ -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) => {
document.documentElement.dataset.inputDevice = 'pc'
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)
}
+3 -1
View File
@@ -9,7 +9,9 @@ import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt }
createRoot(document.getElementById('root')!).render(
<StrictMode>
{new URLSearchParams(window.location.search).get('display') === 'bottom' ? (
<DualScreenBottomDisplay />
<InputProvider>
<DualScreenBottomDisplay />
</InputProvider>
) : (
<DualScreenProvider>
<DualScreenStartupPrompt />
+1 -1
View File
@@ -1 +1 @@
export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'
export const bundledCatalogHash = '2bc6b08d7c902e8f47d74cd0b8c43f5962c1a583cb1f8b42f1ece2b512ad8991'
+7 -1
View File
@@ -6905,5 +6905,11 @@
"full_run": []
}
}
]
],
"hunterStats": {
"bossKills": {},
"bossPets": {},
"pvpMatchesPlayed": 0,
"pvpMatchesWon": 0
}
}
+30
View File
@@ -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)
}