Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e97e5208 | ||
|
|
a849cd2c69 | ||
|
|
a121b2ef74 | ||
|
|
b7cb572631 | ||
|
|
c6e2c61d0a | ||
|
|
f00ad8655b | ||
|
|
6560a049f2 | ||
|
|
89bc1b6ab8 | ||
|
|
5b8c32dc41 | ||
|
|
df6306fe7a | ||
|
|
f6d408f594 | ||
|
|
1797154726 | ||
|
|
bbcc67a54a | ||
|
|
c6251167a5 | ||
|
|
cbe42b6164 | ||
|
|
c2888c287b | ||
|
|
4703017832 | ||
|
|
c0f2daccb1 | ||
|
|
abdf4cc654 | ||
|
|
5449276521 | ||
|
|
787e2bbae9 |
@@ -1,9 +1,51 @@
|
|||||||
# Project Notes
|
# Project Notes
|
||||||
|
|
||||||
|
## Target Hardware: AYN Thor
|
||||||
|
|
||||||
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
|
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
|
||||||
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
|
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
|
||||||
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
|
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
|
||||||
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
|
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
|
||||||
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
|
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
|
||||||
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
|
||||||
|
## Input Requirements
|
||||||
|
|
||||||
|
- Every game screen, menu, dialog, overlay, and gameplay interaction must be fully navigable by controller at all times.
|
||||||
|
- Any time a screen, menu, dialog, overlay, or gameplay interaction is added or changed, verify controller navigation still works for that surface before considering the work complete.
|
||||||
|
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
|
||||||
|
- Focus state must always be visible and predictable when controller navigation is active.
|
||||||
|
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
|
||||||
|
|
||||||
|
## Architecture Requirements
|
||||||
|
|
||||||
|
- Keep systems modular and scoped by responsibility.
|
||||||
|
- Prefer small reusable modules/components over large files with mixed concerns.
|
||||||
|
- Keep shared game logic independent from platform-specific web or mobile wrappers whenever practical.
|
||||||
- Apply game changes to both web version and mobile app version.
|
- Apply game changes to both web version and mobile app version.
|
||||||
|
|
||||||
|
## I Want To Heal 1 vs I Want To Heal 2
|
||||||
|
|
||||||
|
- I Want To Heal 1 is the existing healer-first menu/combat game. Preserve its current progression, saves, inventories, collection logs, combat screens, and content unless the user explicitly asks to change IWT1.
|
||||||
|
- I Want To Heal 2 is the new 2D boss-arena version. Keep IWT2 code, content, save data, inventories, character levels, and collection logs separate from IWT1 so the two modes are not confused.
|
||||||
|
- The app should offer a game-select screen before the main menu: `I Want To Heal 1` launches the old version, and `I Want To Heal 2` launches the new 2D version.
|
||||||
|
- Both games must remain usable from the same Android APK and from `iwanttoheal.phenomrom.com`.
|
||||||
|
- Prefer an explicit folder split for IWT2, such as `src/modes/iwt2/`, with subfolders for screens, simulation, rendering, content, and save/repository code.
|
||||||
|
- Keep IWT2 simulation state independent from rendering. Arena movement, AI, boss attacks, collisions, damage, stun/knockdown, progression, inventory, and collection logs should live in pure TypeScript modules where practical.
|
||||||
|
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
|
||||||
|
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
|
||||||
|
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
|
||||||
|
- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically.
|
||||||
|
- First IWT2 boss conversion target: Bulldrome. Bulldrome should have normal melee tank damage, a charge across the arena that damages and knocks down/stuns hit players for 0.75 seconds, and every 3rd charge should be followed by an AoE ground slam around the boss that also damages and knocks down/stuns hit players for 0.75 seconds.
|
||||||
|
- IWT2 starter party fantasy: player-controlled healer plus visible party members moving in the arena. Paladin tank holds aggro; ranger fires arrows; mage fires fireballs; rogue flanks; warrior fights in melee. Each class should have a distinct icon and color scheme.
|
||||||
|
|
||||||
|
## Performance Requirements
|
||||||
|
|
||||||
|
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
|
||||||
|
- Avoid unnecessary per-frame allocations, polling, timers, layout work, and re-renders.
|
||||||
|
- Keep assets sized appropriately for Thor displays; do not ship oversized textures, images, audio, or data blobs without need.
|
||||||
|
- Prefer event-driven updates and cached calculations for UI and game systems.
|
||||||
|
- Profile or measure when changing hot paths, rendering, animation, input handling, asset loading, or persistent state.
|
||||||
|
|
||||||
|
## Build Requirements
|
||||||
|
|
||||||
|
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 72
|
versionCode 94
|
||||||
versionName "1.0.53"
|
versionName "1.1.15"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.warren.iwanttoheal;
|
||||||
|
|
||||||
|
import android.view.Display;
|
||||||
|
import android.view.Window;
|
||||||
|
import android.view.WindowManager;
|
||||||
|
|
||||||
|
final class AndroidDisplayPerformance {
|
||||||
|
|
||||||
|
private static final float TARGET_REFRESH_RATE = 60.0f;
|
||||||
|
|
||||||
|
private AndroidDisplayPerformance() {}
|
||||||
|
|
||||||
|
static void preferBatteryRefreshRate(Window window, Display display) {
|
||||||
|
if (window == null || display == null) return;
|
||||||
|
Display.Mode currentMode = display.getMode();
|
||||||
|
Display.Mode selectedMode = closestBatteryMode(display, currentMode);
|
||||||
|
if (selectedMode == null) return;
|
||||||
|
|
||||||
|
WindowManager.LayoutParams attributes = window.getAttributes();
|
||||||
|
if (attributes.preferredDisplayModeId == selectedMode.getModeId()) return;
|
||||||
|
attributes.preferredDisplayModeId = selectedMode.getModeId();
|
||||||
|
window.setAttributes(attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Display.Mode closestBatteryMode(Display display, Display.Mode currentMode) {
|
||||||
|
Display.Mode selectedMode = null;
|
||||||
|
float selectedScore = Float.MAX_VALUE;
|
||||||
|
|
||||||
|
for (Display.Mode mode : display.getSupportedModes()) {
|
||||||
|
if (
|
||||||
|
currentMode != null
|
||||||
|
&& (
|
||||||
|
mode.getPhysicalWidth() != currentMode.getPhysicalWidth()
|
||||||
|
|| mode.getPhysicalHeight() != currentMode.getPhysicalHeight()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float refreshRate = mode.getRefreshRate();
|
||||||
|
float score = Math.abs(refreshRate - TARGET_REFRESH_RATE);
|
||||||
|
if (refreshRate > TARGET_REFRESH_RATE + 0.5f) score += 1000.0f;
|
||||||
|
if (score < selectedScore) {
|
||||||
|
selectedMode = mode;
|
||||||
|
selectedScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,45 @@
|
|||||||
package com.warren.iwanttoheal;
|
package com.warren.iwanttoheal;
|
||||||
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.content.pm.PackageInfo;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.SystemClock;
|
import android.os.SystemClock;
|
||||||
|
import android.view.InputDevice;
|
||||||
import android.view.KeyEvent;
|
import android.view.KeyEvent;
|
||||||
|
import android.view.MotionEvent;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||||
|
|
||||||
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
|
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
|
||||||
private static final long DPAD_THROTTLE_MS = 220;
|
private static final String PREFS_NAME = "com.warren.iwanttoheal.performance";
|
||||||
private long lastDpadDispatchAt = 0;
|
private static final String CACHE_CLEARED_VERSION_KEY = "webview_cache_cleared_version";
|
||||||
|
private static final float AXIS_DEAD_ZONE = 0.45f;
|
||||||
|
private static final long DPAD_THROTTLE_MS = 55;
|
||||||
|
private final long[] lastDpadDispatchAt = new long[16];
|
||||||
|
private final Set<String> activeMotionTokens = new HashSet<>();
|
||||||
|
private final Map<String, Long> lastMotionDispatchAt = new HashMap<>();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
clearWebViewServiceWorkers();
|
boolean shouldClearWebViewCache = shouldClearWebViewCacheForCurrentVersion();
|
||||||
|
if (shouldClearWebViewCache) clearWebViewServiceWorkers();
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), getWindowManager().getDefaultDisplay());
|
||||||
if (bridge != null) {
|
if (bridge != null) {
|
||||||
|
if (shouldClearWebViewCache) {
|
||||||
bridge.getWebView().clearCache(true);
|
bridge.getWebView().clearCache(true);
|
||||||
|
markWebViewCacheClearedForCurrentVersion();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loadIntentUrl();
|
loadIntentUrl();
|
||||||
}
|
}
|
||||||
@@ -34,7 +54,25 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
@Override
|
@Override
|
||||||
public void onWindowFocusChanged(boolean hasFocus) {
|
public void onWindowFocusChanged(boolean hasFocus) {
|
||||||
super.onWindowFocusChanged(hasFocus);
|
super.onWindowFocusChanged(hasFocus);
|
||||||
if (hasFocus) enableImmersiveMode();
|
if (hasFocus) {
|
||||||
|
enableImmersiveMode();
|
||||||
|
AndroidDisplayPerformance.preferBatteryRefreshRate(
|
||||||
|
getWindow(),
|
||||||
|
getWindowManager().getDefaultDisplay()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean dispatchTouchEvent(MotionEvent event) {
|
||||||
|
if (
|
||||||
|
event.getActionMasked() == MotionEvent.ACTION_DOWN
|
||||||
|
&& bridge != null
|
||||||
|
&& bridge.getWebView() != null
|
||||||
|
) {
|
||||||
|
bridge.getWebView().requestFocus();
|
||||||
|
}
|
||||||
|
return super.dispatchTouchEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void loadIntentUrl() {
|
private void loadIntentUrl() {
|
||||||
@@ -61,6 +99,34 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
deleteIfExists(new File(webViewData, "Service Worker"));
|
deleteIfExists(new File(webViewData, "Service Worker"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean shouldClearWebViewCacheForCurrentVersion() {
|
||||||
|
return performancePreferences().getLong(CACHE_CLEARED_VERSION_KEY, -1L)
|
||||||
|
!= currentVersionCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markWebViewCacheClearedForCurrentVersion() {
|
||||||
|
performancePreferences()
|
||||||
|
.edit()
|
||||||
|
.putLong(CACHE_CLEARED_VERSION_KEY, currentVersionCode())
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SharedPreferences performancePreferences() {
|
||||||
|
return getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long currentVersionCode() {
|
||||||
|
try {
|
||||||
|
PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
return packageInfo.getLongVersionCode();
|
||||||
|
}
|
||||||
|
return packageInfo.versionCode;
|
||||||
|
} catch (PackageManager.NameNotFoundException exception) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void deleteIfExists(File file) {
|
private void deleteIfExists(File file) {
|
||||||
if (!file.exists()) return;
|
if (!file.exists()) return;
|
||||||
if (file.isDirectory()) {
|
if (file.isDirectory()) {
|
||||||
@@ -81,21 +147,104 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
|
|
||||||
if (event.getAction() == KeyEvent.ACTION_DOWN) {
|
if (event.getAction() == KeyEvent.ACTION_DOWN) {
|
||||||
boolean repeat = event.getRepeatCount() > 0;
|
boolean repeat = event.getRepeatCount() > 0;
|
||||||
if (isDpadToken(token) && shouldThrottleDpad()) return true;
|
if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
|
||||||
String script =
|
dispatchNativeControllerToken(token, repeat);
|
||||||
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
|
|
||||||
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
|
||||||
bridge.getWebView().post(
|
|
||||||
() -> bridge.getWebView().evaluateJavascript(script, null)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean shouldThrottleDpad() {
|
@Override
|
||||||
|
public boolean dispatchGenericMotionEvent(MotionEvent event) {
|
||||||
|
if (
|
||||||
|
bridge == null
|
||||||
|
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|
||||||
|
|| !isControllerMotionEvent(event)
|
||||||
|
) {
|
||||||
|
return super.dispatchGenericMotionEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> currentTokens = new HashSet<>();
|
||||||
|
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();
|
long now = SystemClock.uptimeMillis();
|
||||||
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true;
|
for (String token : currentTokens) {
|
||||||
lastDpadDispatchAt = now;
|
boolean repeat = activeMotionTokens.contains(token);
|
||||||
|
long lastDispatchAt = lastMotionDispatchAt.containsKey(token)
|
||||||
|
? lastMotionDispatchAt.get(token)
|
||||||
|
: 0L;
|
||||||
|
if (!repeat || now - lastDispatchAt >= DPAD_THROTTLE_MS) {
|
||||||
|
dispatchNativeControllerToken(token, repeat);
|
||||||
|
lastMotionDispatchAt.put(token, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeMotionTokens.clear();
|
||||||
|
activeMotionTokens.addAll(currentTokens);
|
||||||
|
|
||||||
|
if (currentTokens.isEmpty() && !hadMotionTokens) {
|
||||||
|
return super.dispatchGenericMotionEvent(event);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isControllerMotionEvent(MotionEvent event) {
|
||||||
|
int source = event.getSource();
|
||||||
|
return (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|
||||||
|
|| (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD
|
||||||
|
|| (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addAxisTokens(
|
||||||
|
Set<String> tokens,
|
||||||
|
float value,
|
||||||
|
String negativeToken,
|
||||||
|
String positiveToken
|
||||||
|
) {
|
||||||
|
if (value <= -AXIS_DEAD_ZONE) tokens.add(negativeToken);
|
||||||
|
if (value >= AXIS_DEAD_ZONE) tokens.add(positiveToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatchNativeControllerToken(String token, boolean repeat) {
|
||||||
|
if (bridge == null || bridge.getWebView() == null) return;
|
||||||
|
String script =
|
||||||
|
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
|
||||||
|
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
||||||
|
bridge.getWebView().post(
|
||||||
|
() -> {
|
||||||
|
bridge.getWebView().requestFocus();
|
||||||
|
bridge.getWebView().evaluateJavascript(script, null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldThrottleDpad(String token) {
|
||||||
|
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
|
||||||
|
long now = SystemClock.uptimeMillis();
|
||||||
|
if (now - lastDpadDispatchAt[buttonIndex] < DPAD_THROTTLE_MS) return true;
|
||||||
|
lastDpadDispatchAt[buttonIndex] = now;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,10 +258,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
private String controllerToken(int keyCode) {
|
private String controllerToken(int keyCode) {
|
||||||
switch (keyCode) {
|
switch (keyCode) {
|
||||||
case KeyEvent.KEYCODE_BUTTON_A:
|
case KeyEvent.KEYCODE_BUTTON_A:
|
||||||
|
case KeyEvent.KEYCODE_DPAD_CENTER:
|
||||||
case KeyEvent.KEYCODE_ENTER:
|
case KeyEvent.KEYCODE_ENTER:
|
||||||
|
case KeyEvent.KEYCODE_NUMPAD_ENTER:
|
||||||
return "Button0";
|
return "Button0";
|
||||||
case KeyEvent.KEYCODE_BUTTON_B:
|
case KeyEvent.KEYCODE_BUTTON_B:
|
||||||
case KeyEvent.KEYCODE_BACK:
|
case KeyEvent.KEYCODE_BACK:
|
||||||
|
case KeyEvent.KEYCODE_ESCAPE:
|
||||||
return "Button1";
|
return "Button1";
|
||||||
case KeyEvent.KEYCODE_BUTTON_X:
|
case KeyEvent.KEYCODE_BUTTON_X:
|
||||||
return "Button2";
|
return "Button2";
|
||||||
|
|||||||
@@ -259,9 +259,11 @@ public class DualScreenPlugin extends Plugin {
|
|||||||
private final class TopDisplayPresentation extends Presentation {
|
private final class TopDisplayPresentation extends Presentation {
|
||||||
|
|
||||||
private final String initialUrl;
|
private final String initialUrl;
|
||||||
|
private final Display display;
|
||||||
|
|
||||||
TopDisplayPresentation(Context context, Display display, String initialUrl) {
|
TopDisplayPresentation(Context context, Display display, String initialUrl) {
|
||||||
super(context, display);
|
super(context, display);
|
||||||
|
this.display = display;
|
||||||
this.initialUrl = initialUrl;
|
this.initialUrl = initialUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +279,7 @@ public class DualScreenPlugin extends Plugin {
|
|||||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||||
);
|
);
|
||||||
|
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), display);
|
||||||
|
|
||||||
FrameLayout container = new FrameLayout(getContext());
|
FrameLayout container = new FrameLayout(getContext());
|
||||||
container.setBackgroundColor(Color.BLACK);
|
container.setBackgroundColor(Color.BLACK);
|
||||||
|
|||||||
@@ -169,7 +169,8 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
completed_dungeon_parts INTEGER NOT NULL DEFAULT 0,
|
completed_dungeon_parts INTEGER NOT NULL DEFAULT 0,
|
||||||
completed_raid_phases INTEGER NOT NULL DEFAULT 0,
|
completed_raid_phases INTEGER NOT NULL DEFAULT 0,
|
||||||
created_ip TEXT NOT NULL,
|
created_ip TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS account_ip_allowances (
|
CREATE TABLE IF NOT EXISTS account_ip_allowances (
|
||||||
@@ -254,6 +255,75 @@ CREATE TABLE IF NOT EXISTS character_inventory (
|
|||||||
PRIMARY KEY (character_id, item_id)
|
PRIMARY KEY (character_id, item_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS character_boss_stats (
|
||||||
|
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||||
|
encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE,
|
||||||
|
kills INTEGER NOT NULL DEFAULT 0 CHECK (kills >= 0),
|
||||||
|
pet_quantity INTEGER NOT NULL DEFAULT 0 CHECK (pet_quantity >= 0),
|
||||||
|
PRIMARY KEY (character_id, encounter_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS character_pvp_stats (
|
||||||
|
character_id INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE,
|
||||||
|
matches_played INTEGER NOT NULL DEFAULT 0 CHECK (matches_played >= 0),
|
||||||
|
matches_won INTEGER NOT NULL DEFAULT 0 CHECK (matches_won >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS action_characters (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
class_id INTEGER NOT NULL REFERENCES classes(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL DEFAULT 1,
|
||||||
|
experience INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bulldrome_coins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bulldrome_normal_clears INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bulldrome_hard_clears INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (account_id, class_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS action_gear_items (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slot TEXT NOT NULL CHECK (slot IN ('weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket')),
|
||||||
|
item_level INTEGER NOT NULL CHECK (item_level BETWEEN 1 AND 5),
|
||||||
|
source TEXT NOT NULL DEFAULT 'bulldrome',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS action_character_inventory (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
action_character_id INTEGER NOT NULL REFERENCES action_characters(id) ON DELETE CASCADE,
|
||||||
|
item_id INTEGER REFERENCES items(id),
|
||||||
|
action_gear_item_id INTEGER REFERENCES action_gear_items(id),
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||||
|
equipped INTEGER NOT NULL DEFAULT 0 CHECK (equipped IN (0, 1))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS action_dungeon_runs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
action_character_id INTEGER NOT NULL REFERENCES action_characters(id) ON DELETE CASCADE,
|
||||||
|
dungeon_id INTEGER NOT NULL REFERENCES dungeons(id),
|
||||||
|
difficulty TEXT NOT NULL CHECK (difficulty IN ('normal', 'hard')),
|
||||||
|
result TEXT NOT NULL CHECK (result IN ('clear', 'wipe')),
|
||||||
|
coins_awarded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duration_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
completed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS action_encounter_loot_rolls (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
action_character_id INTEGER NOT NULL REFERENCES action_characters(id) ON DELETE CASCADE,
|
||||||
|
dungeon_run_id INTEGER REFERENCES action_dungeon_runs(id) ON DELETE CASCADE,
|
||||||
|
encounter_id INTEGER NOT NULL REFERENCES encounters(id),
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id),
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||||
|
difficulty TEXT NOT NULL CHECK (difficulty IN ('normal', 'hard')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS encounter_loot_rolls (
|
CREATE TABLE IF NOT EXISTS encounter_loot_rolls (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -10,12 +10,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^8.4.0",
|
"@capacitor/android": "^8.4.0",
|
||||||
"@capacitor/core": "^8.4.0",
|
"@capacitor/core": "^8.4.0",
|
||||||
|
"phaser": "^4.2.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "^8.4.0",
|
"@capacitor/cli": "^8.4.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -801,6 +803,22 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||||
@@ -2013,6 +2031,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -2952,6 +2976,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/phaser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-9nSQZs4CJ9+V96i2mRC3BBStBcsQWGJJBRpdFYSbpL40/i/QM+wLofaCYMsxNyDk8WJOlHGZUh3uVRKa7lj6yg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^5.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2972,6 +3005,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/plist": {
|
"node_modules/plist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"android:open": "cap open android",
|
"android:open": "cap open android",
|
||||||
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
|
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
|
||||||
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
|
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
|
||||||
|
"release:gui": "python3 scripts/release_game.py",
|
||||||
|
"release:cli": "python3 scripts/release_game_cli.py",
|
||||||
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
|
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
|
||||||
"db:backup": "node scripts/backup-db.mjs",
|
"db:backup": "node scripts/backup-db.mjs",
|
||||||
"db:init": "node scripts/init-db.mjs",
|
"db:init": "node scripts/init-db.mjs",
|
||||||
@@ -26,12 +28,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^8.4.0",
|
"@capacitor/android": "^8.4.0",
|
||||||
"@capacitor/core": "^8.4.0",
|
"@capacitor/core": "^8.4.0",
|
||||||
|
"phaser": "^4.2.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "^8.4.0",
|
"@capacitor/cli": "^8.4.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -241,6 +241,12 @@ addColumnIfMissing('encounters', 'image_url', "TEXT NOT NULL DEFAULT '/boss-plac
|
|||||||
|
|
||||||
addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
|
addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
|
||||||
addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0')
|
addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0')
|
||||||
|
addColumnIfMissing('accounts', 'last_saved_at', 'TEXT')
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE accounts
|
||||||
|
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
|
||||||
|
WHERE last_saved_at IS NULL OR last_saved_at = ''
|
||||||
|
`).run()
|
||||||
addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)')
|
addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)')
|
||||||
|
|
||||||
migrateCharacterAccountConstraint()
|
migrateCharacterAccountConstraint()
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from 'node:crypto'
|
||||||
import { extname, resolve, sep } from 'node:path'
|
import { extname, resolve, sep } from 'node:path'
|
||||||
@@ -10,6 +10,7 @@ const port = Number(process.env.ADMIN_PORT ?? 4174)
|
|||||||
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
|
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
|
||||||
const distPath = fileURLToPath(new URL('../dist', import.meta.url))
|
const distPath = fileURLToPath(new URL('../dist', import.meta.url))
|
||||||
const adminOverridesPath = fileURLToPath(new URL('../db/admin-overrides.sql', import.meta.url))
|
const adminOverridesPath = fileURLToPath(new URL('../db/admin-overrides.sql', import.meta.url))
|
||||||
|
const iwt2BalanceOverridesPath = fileURLToPath(new URL('../src/modes/iwt2/content/balanceOverrides.ts', import.meta.url))
|
||||||
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
|
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
|
||||||
const itemImageDirectory = fileURLToPath(new URL('../data/uploads/items/', import.meta.url))
|
const itemImageDirectory = fileURLToPath(new URL('../data/uploads/items/', import.meta.url))
|
||||||
const dungeonImageDirectory = fileURLToPath(new URL('../data/uploads/dungeons/', import.meta.url))
|
const dungeonImageDirectory = fileURLToPath(new URL('../data/uploads/dungeons/', import.meta.url))
|
||||||
@@ -248,6 +249,89 @@ function saveDungeonImage(database, dungeonId, payload) {
|
|||||||
return imageUrl
|
return imageUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readIwt2BalanceOverrides() {
|
||||||
|
if (!existsSync(iwt2BalanceOverridesPath)) return { bosses: {} }
|
||||||
|
const source = readFileSync(iwt2BalanceOverridesPath, 'utf8')
|
||||||
|
const exportIndex = source.indexOf('IWT2_BALANCE_OVERRIDES')
|
||||||
|
const objectStart = source.indexOf('{', exportIndex)
|
||||||
|
const objectEnd = source.lastIndexOf('}')
|
||||||
|
if (exportIndex < 0 || objectStart < 0 || objectEnd <= objectStart) return { bosses: {} }
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(source.slice(objectStart, objectEnd + 1))
|
||||||
|
return sanitizeIwt2BalanceOverrides(parsed)
|
||||||
|
} catch {
|
||||||
|
return { bosses: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeIwt2BalanceOverrides(value) {
|
||||||
|
const overrides = { bosses: {} }
|
||||||
|
const bosses = value && typeof value === 'object' && value.bosses && typeof value.bosses === 'object'
|
||||||
|
? value.bosses
|
||||||
|
: {}
|
||||||
|
for (const bossId of ['bulldrome', 'yian-kut-ku']) {
|
||||||
|
const boss = bosses[bossId]
|
||||||
|
if (!boss || typeof boss !== 'object') continue
|
||||||
|
const nextBoss = {}
|
||||||
|
if (Number.isFinite(Number(boss.maxHealth))) nextBoss.maxHealth = Math.max(1, Math.round(Number(boss.maxHealth)))
|
||||||
|
if (bossId === 'yian-kut-ku' && Number.isFinite(Number(boss.birdHealth))) {
|
||||||
|
nextBoss.birdHealth = Math.max(1, Math.round(Number(boss.birdHealth)))
|
||||||
|
}
|
||||||
|
if (Object.keys(nextBoss).length > 0) overrides.bosses[bossId] = nextBoss
|
||||||
|
}
|
||||||
|
return overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIwt2BalanceOverrides(overrides) {
|
||||||
|
const source = [
|
||||||
|
"import type { Iwt2BalanceOverrides } from './bosses'",
|
||||||
|
'',
|
||||||
|
'// Generated by local admin panel. Commit this file with intended IWT2 balance changes.',
|
||||||
|
`export const IWT2_BALANCE_OVERRIDES: Iwt2BalanceOverrides = ${JSON.stringify(sanitizeIwt2BalanceOverrides(overrides), null, 2)}`,
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
writeFileSync(iwt2BalanceOverridesPath, source, { mode: 0o644 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIwt2EntityHp(entityId, payload) {
|
||||||
|
const maxHealth = Number(payload.maxHealth)
|
||||||
|
if (!Number.isFinite(maxHealth) || maxHealth <= 0) throw new Error('HP must be greater than zero.')
|
||||||
|
const roundedHealth = Math.round(maxHealth)
|
||||||
|
const overrides = readIwt2BalanceOverrides()
|
||||||
|
const bosses = { ...(overrides.bosses ?? {}) }
|
||||||
|
if (entityId === 'bulldrome' || entityId === 'yian-kut-ku') {
|
||||||
|
bosses[entityId] = {
|
||||||
|
...(bosses[entityId] ?? {}),
|
||||||
|
maxHealth: roundedHealth,
|
||||||
|
}
|
||||||
|
} else if (entityId === 'yian-kut-ku:yian-bird' || entityId === 'yian-bird') {
|
||||||
|
bosses['yian-kut-ku'] = {
|
||||||
|
...(bosses['yian-kut-ku'] ?? {}),
|
||||||
|
birdHealth: roundedHealth,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('IWT2 entity not found.')
|
||||||
|
}
|
||||||
|
const nextOverrides = sanitizeIwt2BalanceOverrides({ bosses })
|
||||||
|
writeIwt2BalanceOverrides(nextOverrides)
|
||||||
|
return nextOverrides
|
||||||
|
}
|
||||||
|
|
||||||
|
function listIwt2BossAssets() {
|
||||||
|
if (!existsSync(bossImageDirectory)) return []
|
||||||
|
return readdirSync(bossImageDirectory, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile() && bossImageContentTypes[extname(entry.name).toLowerCase()])
|
||||||
|
.map((entry) => {
|
||||||
|
const imagePath = resolve(bossImageDirectory, entry.name)
|
||||||
|
return {
|
||||||
|
filename: entry.name,
|
||||||
|
size: statSync(imagePath).size,
|
||||||
|
url: `/api/boss-images/${entry.name}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||||
|
}
|
||||||
|
|
||||||
function sendFile(response, filePath) {
|
function sendFile(response, filePath) {
|
||||||
const contentTypes = {
|
const contentTypes = {
|
||||||
'.css': 'text/css; charset=utf-8',
|
'.css': 'text/css; charset=utf-8',
|
||||||
@@ -308,6 +392,22 @@ const server = createServer(async (request, response) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/admin/iwt2/data' && request.method === 'GET') {
|
||||||
|
sendJson(response, 200, {
|
||||||
|
balanceOverrides: readIwt2BalanceOverrides(),
|
||||||
|
bossAssets: listIwt2BossAssets(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const iwt2HpMatch = request.url.match(/^\/api\/admin\/iwt2\/entities\/([^/]+)\/hp$/)
|
||||||
|
if (iwt2HpMatch && request.method === 'PUT') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
const balanceOverrides = updateIwt2EntityHp(decodeURIComponent(iwt2HpMatch[1]), payload)
|
||||||
|
sendJson(response, 200, { ok: true, balanceOverrides })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!existsSync(databasePath)) {
|
if (!existsSync(databasePath)) {
|
||||||
sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
|
sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ export function normalizeCatalogProfile(profile) {
|
|||||||
})),
|
})),
|
||||||
craftingRecipes: profile.craftingRecipes.map(normalizeRecipe),
|
craftingRecipes: profile.craftingRecipes.map(normalizeRecipe),
|
||||||
dungeons: profile.dungeons.map(normalizeDungeon),
|
dungeons: profile.dungeons.map(normalizeDungeon),
|
||||||
|
hunterStats: {
|
||||||
|
bossKills: {},
|
||||||
|
bossPets: {},
|
||||||
|
pvpMatchesPlayed: 0,
|
||||||
|
pvpMatchesWon: 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { isIP } from 'node:net'
|
|||||||
import { extname, resolve, sep } from 'node:path'
|
import { extname, resolve, sep } from 'node:path'
|
||||||
import { DatabaseSync } from 'node:sqlite'
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
import { catalogPayload } from './catalog.mjs'
|
import { catalogPayload } from './catalog.mjs'
|
||||||
|
import {
|
||||||
|
catchUpExperienceReward as catchUpExperienceRewardForTarget,
|
||||||
|
coinDropQuantity,
|
||||||
|
roguelikeCoinItemLevel,
|
||||||
|
} from '../src/shared/rewardRules.mjs'
|
||||||
|
|
||||||
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
|
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
|
||||||
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
|
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
|
||||||
@@ -32,6 +37,35 @@ const pvpMatches = new Map()
|
|||||||
const pvpQueueTtlMs = 15 * 1000
|
const pvpQueueTtlMs = 15 * 1000
|
||||||
const pvpMatchTtlMs = 60 * 60 * 1000
|
const pvpMatchTtlMs = 60 * 60 * 1000
|
||||||
|
|
||||||
|
function addColumnIfMissing(database, table, column, definition) {
|
||||||
|
const columns = database.prepare(`PRAGMA table_info(${table})`).all()
|
||||||
|
if (!columns.some((candidate) => candidate.name === column)) {
|
||||||
|
database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRuntimeMigrations(database) {
|
||||||
|
addColumnIfMissing(database, 'accounts', 'last_saved_at', 'TEXT')
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE accounts
|
||||||
|
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
|
||||||
|
WHERE last_saved_at IS NULL OR last_saved_at = ''
|
||||||
|
`).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqliteDateFromMs(value) {
|
||||||
|
const timestamp = Number(value)
|
||||||
|
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
return new Date(timestamp).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function epochMsFromSqliteDate(value) {
|
||||||
|
const timestamp = Date.parse(String(value ?? ''))
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0
|
||||||
|
}
|
||||||
|
|
||||||
function sendJson(response, status, body, headers = {}) {
|
function sendJson(response, status, body, headers = {}) {
|
||||||
response.statusCode = status
|
response.statusCode = status
|
||||||
response.setHeader('Content-Type', 'application/json')
|
response.setHeader('Content-Type', 'application/json')
|
||||||
@@ -39,6 +73,19 @@ function sendJson(response, status, body, headers = {}) {
|
|||||||
response.end(JSON.stringify(body))
|
response.end(JSON.stringify(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function touchAccountSave(database, accountId) {
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE accounts
|
||||||
|
SET last_saved_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMutatingResult(response, database, accountId, body) {
|
||||||
|
touchAccountSave(database, accountId)
|
||||||
|
sendJson(response, 200, body)
|
||||||
|
}
|
||||||
|
|
||||||
function configuredCorsOrigins() {
|
function configuredCorsOrigins() {
|
||||||
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
|
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -251,10 +298,7 @@ function catchUpExperienceReward(database, accountId, characterId, baseReward, c
|
|||||||
FROM level_progression
|
FROM level_progression
|
||||||
WHERE level = ?
|
WHERE level = ?
|
||||||
`).get(targetLevel)?.experienceRequired ?? currentExperience
|
`).get(targetLevel)?.experienceRequired ?? currentExperience
|
||||||
const gap = Math.max(0, targetExperience - currentExperience)
|
return catchUpExperienceRewardForTarget(baseReward, currentExperience, targetExperience)
|
||||||
if (gap <= 0) return baseReward
|
|
||||||
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
|
|
||||||
return doubledBase * 2 + (baseReward - doubledBase)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUsername(value) {
|
function normalizeUsername(value) {
|
||||||
@@ -813,6 +857,21 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
AND completed_parts >= 1
|
AND completed_parts >= 1
|
||||||
GROUP BY dungeon_id
|
GROUP BY dungeon_id
|
||||||
`).all(characterId).map((row) => [row.dungeonId, row.count]))
|
`).all(characterId).map((row) => [row.dungeonId, row.count]))
|
||||||
|
const bossStatRows = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
encounter_id AS encounterId,
|
||||||
|
kills,
|
||||||
|
pet_quantity AS petQuantity
|
||||||
|
FROM character_boss_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).all(characterId)
|
||||||
|
const pvpStats = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
matches_played AS matchesPlayed,
|
||||||
|
matches_won AS matchesWon
|
||||||
|
FROM character_pvp_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).get(characterId)
|
||||||
|
|
||||||
const settings = Object.fromEntries(
|
const settings = Object.fromEntries(
|
||||||
database.prepare('SELECT key, value FROM game_settings').all()
|
database.prepare('SELECT key, value FROM game_settings').all()
|
||||||
@@ -928,6 +987,12 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
.map((run) => ({ ...run, rank: Number(run.rank) })),
|
.map((run) => ({ ...run, rank: Number(run.rank) })),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
hunterStats: {
|
||||||
|
bossKills: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.kills])),
|
||||||
|
bossPets: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.petQuantity])),
|
||||||
|
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
|
||||||
|
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1004,7 +1069,8 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
|||||||
const account = database.prepare(`
|
const account = database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
completed_dungeon_parts AS completedDungeonParts,
|
completed_dungeon_parts AS completedDungeonParts,
|
||||||
completed_raid_phases AS completedRaidPhases
|
completed_raid_phases AS completedRaidPhases,
|
||||||
|
last_saved_at AS lastSavedAt
|
||||||
FROM accounts
|
FROM accounts
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).get(accountId)
|
`).get(accountId)
|
||||||
@@ -1023,8 +1089,19 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
|||||||
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
|
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
|
||||||
?? characters[0]?.name
|
?? characters[0]?.name
|
||||||
?? 'Mira'
|
?? 'Mira'
|
||||||
|
const bossStats = database.prepare(`
|
||||||
|
SELECT encounter_id AS encounterId, kills, pet_quantity AS petQuantity
|
||||||
|
FROM character_boss_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).all(activeCharacterId)
|
||||||
|
const pvpStats = database.prepare(`
|
||||||
|
SELECT matches_played AS matchesPlayed, matches_won AS matchesWon
|
||||||
|
FROM character_pvp_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).get(activeCharacterId)
|
||||||
return {
|
return {
|
||||||
version: 3,
|
version: 4,
|
||||||
|
updatedAt: epochMsFromSqliteDate(account?.lastSavedAt),
|
||||||
characterName,
|
characterName,
|
||||||
activeClassId,
|
activeClassId,
|
||||||
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
||||||
@@ -1036,6 +1113,10 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
lootRolls: {},
|
lootRolls: {},
|
||||||
|
bossKills: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.kills])),
|
||||||
|
bossPets: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.petQuantity])),
|
||||||
|
pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
|
||||||
|
pvpMatchesWon: pvpStats?.matchesWon ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1050,7 +1131,7 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
if (
|
if (
|
||||||
!save
|
!save
|
||||||
|| typeof save !== 'object'
|
|| typeof save !== 'object'
|
||||||
|| Number(save.version) !== 3
|
|| (Number(save.version) !== 4 && Number(save.version) !== 3)
|
||||||
|| typeof save.characterName !== 'string'
|
|| typeof save.characterName !== 'string'
|
||||||
|| !save.characters
|
|| !save.characters
|
||||||
|| typeof save.characters !== 'object'
|
|| typeof save.characters !== 'object'
|
||||||
@@ -1127,11 +1208,12 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
|
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
UPDATE accounts
|
UPDATE accounts
|
||||||
SET completed_dungeon_parts = ?, completed_raid_phases = ?
|
SET completed_dungeon_parts = ?, completed_raid_phases = ?, last_saved_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
clampInteger(save.completedDungeonParts, 0, 0, 3),
|
clampInteger(save.completedDungeonParts, 0, 0, 3),
|
||||||
clampInteger(save.completedRaidPhases, 0, 0, 3),
|
clampInteger(save.completedRaidPhases, 0, 0, 3),
|
||||||
|
sqliteDateFromMs(save.updatedAt),
|
||||||
accountId,
|
accountId,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1147,6 +1229,10 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`)
|
`)
|
||||||
|
const insertBossStats = database.prepare(`
|
||||||
|
INSERT INTO character_boss_stats (character_id, encounter_id, kills, pet_quantity)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`)
|
||||||
|
|
||||||
for (const classId of classIds) {
|
for (const classId of classIds) {
|
||||||
const local = save.characters[classId]
|
const local = save.characters[classId]
|
||||||
@@ -1211,6 +1297,14 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
DELETE FROM character_inventory
|
DELETE FROM character_inventory
|
||||||
WHERE character_id = ?
|
WHERE character_id = ?
|
||||||
`).run(characterId)
|
`).run(characterId)
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM character_boss_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).run(characterId)
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM character_pvp_stats
|
||||||
|
WHERE character_id = ?
|
||||||
|
`).run(characterId)
|
||||||
const inventoryByItemId = new Map()
|
const inventoryByItemId = new Map()
|
||||||
const equippedSlots = new Set()
|
const equippedSlots = new Set()
|
||||||
for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
|
for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
|
||||||
@@ -1233,6 +1327,34 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
for (const [itemId, itemState] of inventoryByItemId) {
|
for (const [itemId, itemState] of inventoryByItemId) {
|
||||||
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
|
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
|
||||||
}
|
}
|
||||||
|
if (classId === Number(save.activeClassId)) {
|
||||||
|
const bossKills = save.bossKills && typeof save.bossKills === 'object' ? save.bossKills : {}
|
||||||
|
const bossPets = save.bossPets && typeof save.bossPets === 'object' ? save.bossPets : {}
|
||||||
|
for (const [encounterId, rawKills] of Object.entries(bossKills)) {
|
||||||
|
const id = Number(encounterId)
|
||||||
|
if (!Number.isInteger(id)) continue
|
||||||
|
insertBossStats.run(
|
||||||
|
characterId,
|
||||||
|
id,
|
||||||
|
clampInteger(rawKills, 0, 0, 1000000),
|
||||||
|
clampInteger(bossPets[encounterId], 0, 0, 1000000),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const [encounterId, rawPets] of Object.entries(bossPets)) {
|
||||||
|
if (Object.hasOwn(bossKills, encounterId)) continue
|
||||||
|
const id = Number(encounterId)
|
||||||
|
if (!Number.isInteger(id)) continue
|
||||||
|
insertBossStats.run(characterId, id, 0, clampInteger(rawPets, 0, 0, 1000000))
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO character_pvp_stats (character_id, matches_played, matches_won)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
characterId,
|
||||||
|
clampInteger(save.pvpMatchesPlayed, 0, 0, 1000000),
|
||||||
|
clampInteger(save.pvpMatchesWon, 0, 0, 1000000),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let syncedClassId = clampInteger(
|
let syncedClassId = clampInteger(
|
||||||
@@ -1345,17 +1467,6 @@ function formatLootRoll(database, context, record, dropChance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function coinDropQuantity() {
|
|
||||||
const roll = Math.random()
|
|
||||||
if (roll < 0.15) return 3
|
|
||||||
if (roll < 0.5) return 2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function roguelikeCoinItemLevel(stage) {
|
|
||||||
return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
function awardRoguelikeCoin(database, characterId, sourceEncounterId, stage) {
|
function awardRoguelikeCoin(database, characterId, sourceEncounterId, stage) {
|
||||||
if (!sourceEncounterId || !stage) return null
|
if (!sourceEncounterId || !stage) return null
|
||||||
const coin = database.prepare(`
|
const coin = database.prepare(`
|
||||||
@@ -2307,7 +2418,18 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3))
|
const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3))
|
||||||
const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level'
|
const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level'
|
||||||
? 'pvp-boss-quarter-level'
|
? 'pvp-boss-quarter-level'
|
||||||
|
: runMetrics?.experienceMode === 'pvp-fight-twelfth-level'
|
||||||
|
? 'pvp-fight-twelfth-level'
|
||||||
|
: runMetrics?.experienceMode === 'pvp-match-win-half-level'
|
||||||
|
? 'pvp-match-win-half-level'
|
||||||
|
: runMetrics?.experienceMode === 'pvp-stadium-round-win-quarter-level'
|
||||||
|
? 'pvp-stadium-round-win-quarter-level'
|
||||||
|
: runMetrics?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
|
||||||
|
? 'pvp-stadium-round-loss-tenth-level'
|
||||||
|
: runMetrics?.experienceMode === 'pvp-stadium-match-half-level'
|
||||||
|
? 'pvp-stadium-match-half-level'
|
||||||
: 'default'
|
: 'default'
|
||||||
|
const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared)
|
||||||
const resourceSpent = Number(runMetrics?.resourceSpent)
|
const resourceSpent = Number(runMetrics?.resourceSpent)
|
||||||
const durationSeconds = Number(runMetrics?.durationSeconds)
|
const durationSeconds = Number(runMetrics?.durationSeconds)
|
||||||
if (!Number.isInteger(dungeonId) || dungeonId < 1) {
|
if (!Number.isInteger(dungeonId) || dungeonId < 1) {
|
||||||
@@ -2322,6 +2444,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) {
|
if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) {
|
||||||
throw new Error('The roguelike boss total is invalid.')
|
throw new Error('The roguelike boss total is invalid.')
|
||||||
}
|
}
|
||||||
|
if (!Number.isInteger(fightsCleared) || fightsCleared < 0 || fightsCleared > 100000) {
|
||||||
|
throw new Error('The roguelike fight total is invalid.')
|
||||||
|
}
|
||||||
if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
|
if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
|
||||||
throw new Error('The run resource total is invalid.')
|
throw new Error('The run resource total is invalid.')
|
||||||
}
|
}
|
||||||
@@ -2374,14 +2499,15 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
`).get(maxLevel).experienceRequired
|
`).get(maxLevel).experienceRequired
|
||||||
let newExperience = character.experience
|
let newExperience = character.experience
|
||||||
let newLevel = character.level
|
let newLevel = character.level
|
||||||
if (experienceMode === 'pvp-boss-quarter-level') {
|
if (experienceMode === 'pvp-boss-quarter-level' || experienceMode === 'pvp-fight-twelfth-level') {
|
||||||
const catchUpTargetLevel = database.prepare(`
|
const catchUpTargetLevel = database.prepare(`
|
||||||
SELECT COALESCE(MAX(level), 0) AS level
|
SELECT COALESCE(MAX(level), 0) AS level
|
||||||
FROM characters
|
FROM characters
|
||||||
WHERE account_id = ?
|
WHERE account_id = ?
|
||||||
AND id != ?
|
AND id != ?
|
||||||
`).get(accountId, characterId).level
|
`).get(accountId, characterId).level
|
||||||
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
|
const rewardUnits = experienceMode === 'pvp-boss-quarter-level' ? bossesCleared : fightsCleared
|
||||||
|
for (let rewardIndex = 0; rewardIndex < rewardUnits && newExperience < maxExperience; rewardIndex += 1) {
|
||||||
const currentLevelFloor = database.prepare(`
|
const currentLevelFloor = database.prepare(`
|
||||||
SELECT experience_required AS experienceRequired
|
SELECT experience_required AS experienceRequired
|
||||||
FROM level_progression
|
FROM level_progression
|
||||||
@@ -2395,7 +2521,9 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
WHERE level = ?
|
WHERE level = ?
|
||||||
`).get(newLevel + 1).experienceRequired
|
`).get(newLevel + 1).experienceRequired
|
||||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||||
const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
|
const rewardRate = experienceMode === 'pvp-boss-quarter-level'
|
||||||
|
? (catchUpTargetLevel > newLevel ? 0.5 : 0.25)
|
||||||
|
: (catchUpTargetLevel > newLevel ? 1 / 6 : 1 / 12)
|
||||||
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
||||||
newLevel = database.prepare(`
|
newLevel = database.prepare(`
|
||||||
SELECT MAX(level) AS level
|
SELECT MAX(level) AS level
|
||||||
@@ -2403,6 +2531,36 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
WHERE experience_required <= ?
|
WHERE experience_required <= ?
|
||||||
`).get(newExperience).level
|
`).get(newExperience).level
|
||||||
}
|
}
|
||||||
|
} else if (
|
||||||
|
experienceMode === 'pvp-match-win-half-level'
|
||||||
|
|| experienceMode === 'pvp-stadium-round-win-quarter-level'
|
||||||
|
|| experienceMode === 'pvp-stadium-round-loss-tenth-level'
|
||||||
|
|| experienceMode === 'pvp-stadium-match-half-level'
|
||||||
|
) {
|
||||||
|
const currentLevelFloor = database.prepare(`
|
||||||
|
SELECT experience_required AS experienceRequired
|
||||||
|
FROM level_progression
|
||||||
|
WHERE level = ?
|
||||||
|
`).get(newLevel).experienceRequired
|
||||||
|
const nextLevelExperience = newLevel >= maxLevel
|
||||||
|
? maxExperience
|
||||||
|
: database.prepare(`
|
||||||
|
SELECT experience_required AS experienceRequired
|
||||||
|
FROM level_progression
|
||||||
|
WHERE level = ?
|
||||||
|
`).get(newLevel + 1).experienceRequired
|
||||||
|
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||||
|
const rewardRate = experienceMode === 'pvp-stadium-round-win-quarter-level'
|
||||||
|
? 0.25
|
||||||
|
: experienceMode === 'pvp-stadium-round-loss-tenth-level'
|
||||||
|
? 0.1
|
||||||
|
: 0.5
|
||||||
|
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
||||||
|
newLevel = database.prepare(`
|
||||||
|
SELECT MAX(level) AS level
|
||||||
|
FROM level_progression
|
||||||
|
WHERE experience_required <= ?
|
||||||
|
`).get(newExperience).level
|
||||||
} else {
|
} else {
|
||||||
const baseExperienceReward = Math.round(
|
const baseExperienceReward = Math.round(
|
||||||
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
|
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
|
||||||
@@ -2555,7 +2713,7 @@ function cleanupPvpMemory(now = Date.now()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validatePvpContentType(value) {
|
function validatePvpContentType(value) {
|
||||||
if (value !== 'dungeon' && value !== 'raid') {
|
if (value !== 'dungeon' && value !== 'raid' && value !== 'stadium') {
|
||||||
throw new Error('The PvP content type is invalid.')
|
throw new Error('The PvP content type is invalid.')
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
@@ -2589,10 +2747,30 @@ function pvpSnapshot(match) {
|
|||||||
statuses: match.statuses,
|
statuses: match.statuses,
|
||||||
progress: match.progress,
|
progress: match.progress,
|
||||||
upgradeChoices: match.upgradeChoices,
|
upgradeChoices: match.upgradeChoices,
|
||||||
|
rematchRequests: match.rematchRequests,
|
||||||
updatedAt: match.updatedAt,
|
updatedAt: match.updatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPvpMatch(contentType, startStage, players, now = Date.now()) {
|
||||||
|
const matchId = randomBytes(12).toString('base64url')
|
||||||
|
const match = {
|
||||||
|
id: matchId,
|
||||||
|
contentType,
|
||||||
|
startStage,
|
||||||
|
createdAt: now,
|
||||||
|
players,
|
||||||
|
states: {},
|
||||||
|
statuses: {},
|
||||||
|
progress: {},
|
||||||
|
upgradeChoices: {},
|
||||||
|
rematchRequests: {},
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
pvpMatches.set(matchId, match)
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
function joinPvpQueue(session, payload) {
|
function joinPvpQueue(session, payload) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
cleanupPvpMemory(now)
|
cleanupPvpMemory(now)
|
||||||
@@ -2622,24 +2800,11 @@ function joinPvpQueue(session, payload) {
|
|||||||
.sort((left, right) => left.createdAt - right.createdAt)[0]
|
.sort((left, right) => left.createdAt - right.createdAt)[0]
|
||||||
const player = pvpPlayerInfo(session)
|
const player = pvpPlayerInfo(session)
|
||||||
if (opponent) {
|
if (opponent) {
|
||||||
const matchId = randomBytes(12).toString('base64url')
|
const match = createPvpMatch(contentType, startStage, {
|
||||||
const match = {
|
|
||||||
id: matchId,
|
|
||||||
contentType,
|
|
||||||
startStage,
|
|
||||||
createdAt: now,
|
|
||||||
players: {
|
|
||||||
a: { side: 'a', ...opponent.player },
|
a: { side: 'a', ...opponent.player },
|
||||||
b: { side: 'b', ...player },
|
b: { side: 'b', ...player },
|
||||||
},
|
}, now)
|
||||||
states: {},
|
opponent.matchId = match.id
|
||||||
statuses: {},
|
|
||||||
progress: {},
|
|
||||||
upgradeChoices: {},
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
pvpMatches.set(matchId, match)
|
|
||||||
opponent.matchId = matchId
|
|
||||||
opponent.updatedAt = now
|
opponent.updatedAt = now
|
||||||
const ticketId = randomBytes(12).toString('base64url')
|
const ticketId = randomBytes(12).toString('base64url')
|
||||||
pvpQueue.set(ticketId, {
|
pvpQueue.set(ticketId, {
|
||||||
@@ -2649,7 +2814,7 @@ function joinPvpQueue(session, payload) {
|
|||||||
contentType,
|
contentType,
|
||||||
startStage,
|
startStage,
|
||||||
player,
|
player,
|
||||||
matchId,
|
matchId: match.id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
@@ -2720,7 +2885,7 @@ function requirePvpMatchForSession(session, matchId) {
|
|||||||
|
|
||||||
function updatePvpMatchState(session, matchId, payload) {
|
function updatePvpMatchState(session, matchId, payload) {
|
||||||
const { match, side } = requirePvpMatchForSession(session, matchId)
|
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||||
const status = ['playing', 'upgrade-choice', 'won', 'lost'].includes(payload.status)
|
const status = ['playing', 'upgrade-choice', 'shop', 'won', 'lost'].includes(payload.status)
|
||||||
? payload.status
|
? payload.status
|
||||||
: 'playing'
|
: 'playing'
|
||||||
const progress = {
|
const progress = {
|
||||||
@@ -2731,6 +2896,19 @@ function updatePvpMatchState(session, matchId, payload) {
|
|||||||
alive: Boolean(payload.alive),
|
alive: Boolean(payload.alive),
|
||||||
elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)),
|
elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)),
|
||||||
}
|
}
|
||||||
|
const currentProgress = match.progress[side]
|
||||||
|
if (
|
||||||
|
currentProgress
|
||||||
|
&& (
|
||||||
|
progress.stage < currentProgress.stage
|
||||||
|
|| (
|
||||||
|
progress.stage === currentProgress.stage
|
||||||
|
&& progress.encounterIndex < currentProgress.encounterIndex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return pvpSnapshot(match)
|
||||||
|
}
|
||||||
match.states[side] = payload.state ?? null
|
match.states[side] = payload.state ?? null
|
||||||
match.statuses[side] = status
|
match.statuses[side] = status
|
||||||
match.progress[side] = progress
|
match.progress[side] = progress
|
||||||
@@ -2746,11 +2924,40 @@ function submitPvpUpgradeChoice(session, matchId, payload) {
|
|||||||
encounterIndex,
|
encounterIndex,
|
||||||
buffId: String(payload.buffId ?? ''),
|
buffId: String(payload.buffId ?? ''),
|
||||||
debuffId: String(payload.debuffId ?? ''),
|
debuffId: String(payload.debuffId ?? ''),
|
||||||
|
purchases: Array.isArray(payload.purchases) ? payload.purchases.map((purchase) => String(purchase)) : [],
|
||||||
|
shopReady: Boolean(payload.shopReady),
|
||||||
}
|
}
|
||||||
match.updatedAt = Date.now()
|
match.updatedAt = Date.now()
|
||||||
return pvpSnapshot(match)
|
return pvpSnapshot(match)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestPvpRematch(session, matchId) {
|
||||||
|
const { match, side } = requirePvpMatchForSession(session, matchId)
|
||||||
|
if (match.nextMatchId) {
|
||||||
|
const nextMatch = pvpMatches.get(match.nextMatchId)
|
||||||
|
if (nextMatch) return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
|
||||||
|
}
|
||||||
|
match.rematchRequests = match.rematchRequests ?? {}
|
||||||
|
match.rematchRequests[side] = true
|
||||||
|
match.updatedAt = Date.now()
|
||||||
|
const opponentSide = side === 'a' ? 'b' : 'a'
|
||||||
|
if (!match.rematchRequests[opponentSide]) {
|
||||||
|
return { status: 'waiting', match: pvpSnapshot(match), side }
|
||||||
|
}
|
||||||
|
const nextMatch = createPvpMatch(
|
||||||
|
match.contentType,
|
||||||
|
match.startStage,
|
||||||
|
{
|
||||||
|
a: match.players.a,
|
||||||
|
b: match.players.b,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
)
|
||||||
|
match.nextMatchId = nextMatch.id
|
||||||
|
match.updatedAt = Date.now()
|
||||||
|
return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
|
||||||
|
}
|
||||||
|
|
||||||
export function gameApiPlugin() {
|
export function gameApiPlugin() {
|
||||||
return {
|
return {
|
||||||
name: 'ashen-halls-game-api',
|
name: 'ashen-halls-game-api',
|
||||||
@@ -2842,6 +3049,7 @@ export async function handleAuthApiRequest(request, response, next = null) {
|
|||||||
|
|
||||||
const database = new DatabaseSync(databasePath)
|
const database = new DatabaseSync(databasePath)
|
||||||
database.exec('PRAGMA foreign_keys = ON')
|
database.exec('PRAGMA foreign_keys = ON')
|
||||||
|
ensureRuntimeMigrations(database)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = requestIp(request)
|
const ip = requestIp(request)
|
||||||
@@ -2898,6 +3106,7 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const database = new DatabaseSync(databasePath)
|
const database = new DatabaseSync(databasePath)
|
||||||
database.exec('PRAGMA foreign_keys = ON')
|
database.exec('PRAGMA foreign_keys = ON')
|
||||||
|
ensureRuntimeMigrations(database)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = requestIp(request)
|
const ip = requestIp(request)
|
||||||
@@ -2936,7 +3145,7 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
if (request.url === '/api/profile' && request.method === 'PUT') {
|
if (request.url === '/api/profile' && request.method === 'PUT') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload)
|
const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload)
|
||||||
sendJson(response, 200, getProfile(database, newCharacterId, session.accountId))
|
sendMutatingResult(response, database, session.accountId, getProfile(database, newCharacterId, session.accountId))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2971,6 +3180,12 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pvpRematch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/)
|
||||||
|
if (pvpRematch && request.method === 'POST') {
|
||||||
|
sendJson(response, 200, requestPvpRematch(session, pvpRematch[1]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/)
|
const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/)
|
||||||
if (pvpMatch && request.method === 'GET') {
|
if (pvpMatch && request.method === 'GET') {
|
||||||
sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match))
|
sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match))
|
||||||
@@ -2980,9 +3195,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
||||||
if (dungeonCompletion && request.method === 'POST') {
|
if (dungeonCompletion && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
completeDungeon(
|
completeDungeon(
|
||||||
database,
|
database,
|
||||||
session.characterId,
|
session.characterId,
|
||||||
@@ -2997,9 +3213,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
if (request.url === '/api/roguelike/complete' && request.method === 'POST') {
|
if (request.url === '/api/roguelike/complete' && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
completeRoguelike(database, session.characterId, session.accountId, payload),
|
completeRoguelike(database, session.characterId, session.accountId, payload),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3007,24 +3224,26 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/)
|
const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/)
|
||||||
if (talentAllocation && request.method === 'POST') {
|
if (talentAllocation && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
allocateTalent(database, session.characterId, Number(talentAllocation[1])),
|
allocateTalent(database, session.characterId, Number(talentAllocation[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.url === '/api/talents/reset' && request.method === 'POST') {
|
if (request.url === '/api/talents/reset' && request.method === 'POST') {
|
||||||
sendJson(response, 200, resetTalents(database, session.characterId))
|
sendMutatingResult(response, database, session.accountId, resetTalents(database, session.characterId))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/)
|
const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/)
|
||||||
if (itemEquip && request.method === 'POST') {
|
if (itemEquip && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
equipItem(database, session.characterId, Number(itemEquip[1])),
|
equipItem(database, session.characterId, Number(itemEquip[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3032,9 +3251,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/)
|
const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/)
|
||||||
if (itemDiscard && request.method === 'POST') {
|
if (itemDiscard && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
discardExtraItem(database, session.characterId, Number(itemDiscard[1])),
|
discardExtraItem(database, session.characterId, Number(itemDiscard[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3042,9 +3262,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/)
|
const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/)
|
||||||
if (itemBreakdown && request.method === 'POST') {
|
if (itemBreakdown && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
breakdownItem(database, session.characterId, Number(itemBreakdown[1])),
|
breakdownItem(database, session.characterId, Number(itemBreakdown[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3052,9 +3273,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/)
|
const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/)
|
||||||
if (recipeCraft && request.method === 'POST') {
|
if (recipeCraft && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
craftItem(database, session.characterId, Number(recipeCraft[1])),
|
craftItem(database, session.characterId, Number(recipeCraft[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3062,9 +3284,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
|
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
|
||||||
if (itemUpgrade && request.method === 'POST') {
|
if (itemUpgrade && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
|
upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3073,9 +3296,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
|
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
|
||||||
if (encounterLootRoll && request.method === 'POST') {
|
if (encounterLootRoll && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
rollEncounterLoot(
|
rollEncounterLoot(
|
||||||
database,
|
database,
|
||||||
session.characterId,
|
session.characterId,
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import { AdminScreen } from './components/AdminScreen'
|
import { AdminScreen } from './components/AdminScreen'
|
||||||
|
import { InputProvider } from './input'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<InputProvider>
|
||||||
<AdminScreen onBack={() => window.close()} />
|
<AdminScreen onBack={() => window.close()} />
|
||||||
|
</InputProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Combat Modules
|
||||||
|
|
||||||
|
Shared combat code is split by responsibility:
|
||||||
|
|
||||||
|
- `spellEffects.ts` owns spell target plans and the shared spell-effect applier.
|
||||||
|
- `combatEngine.ts` owns repeated per-member tick math: damage reduction, shields, HoTs, poison, debuff timers, and bounce-heal jumps.
|
||||||
|
- `combatPresentation.ts` owns UI-facing caps and grouping for combat logs and floating combat text.
|
||||||
|
- Screen files provide `SpellEffectProfile` objects for mode-specific numbers.
|
||||||
|
- `spellCasting.ts` owns cast readiness and cooldown writes.
|
||||||
|
- `pvpSpellCasting.ts` owns the shared PvP cast pipeline: readiness validation, live target validation, effect application, floating-heal emission, and side-state writes.
|
||||||
|
- `combatTick.ts` owns small tick helpers such as resource regeneration and cooldown decay.
|
||||||
|
- `cpuAi.ts` owns CPU healer timing, mistake rolls, spell choice, and turn execution.
|
||||||
|
- `stackCounts.ts` owns stacked buff/upgrade count maps and summaries.
|
||||||
|
- `spellModifiers.ts` owns stack-based spell modifiers such as cost, cooldown, extra targets, free casts, and power multipliers.
|
||||||
|
- `combatStateTransitions.ts` owns shared combat state writes such as applying a completed cast to party, resource, cooldown, and free-cast fields.
|
||||||
|
- `stadiumLifecycle.ts` owns pure Stadium round/shop decisions: round outcome resolution, shop point awards, and CPU purchase selection.
|
||||||
|
- `pvpRoguelikeLifecycle.ts` owns pure PvP roguelike lifecycle decisions: live snapshot outcomes, combat tick outcomes, upgrade-complete checks, and next-stage progression.
|
||||||
|
- `pvpRoguelikeUpgrades.ts` owns PvP roguelike upgrade application, revive cleanup, party recovery, resource restore, cooldown reset, and next enemy health setup.
|
||||||
|
- `pvpRoguelikeMatchSetup.ts` owns PvP roguelike match-start side creation, first segment setup, live opponent naming, and shared reset defaults.
|
||||||
|
- `stadiumMatchSetup.ts` owns Stadium starter side creation, local/live match setup, live opponent naming, and shared reset defaults.
|
||||||
|
- `pveRoguelikeRunSetup.ts` owns PvE combat-state creation and roguelike run reset defaults.
|
||||||
|
- `rewardSummaries.ts` owns reward summary initialization, unlocked ability merging, XP/level/talent accumulation, and PvP roguelike boss loot accumulation.
|
||||||
|
- `dualScreenPayloads.ts` owns dual-screen combat payload construction and mode-specific status normalization.
|
||||||
|
- `pvpLiveLifecycle.ts` owns live PvP polling, local progress publication, and rematch request state.
|
||||||
|
|
||||||
|
## SpellEffectProfile
|
||||||
|
|
||||||
|
Use a profile when spell behavior is the same shape but different by mode.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- PvE stores Renew-style effects in `hotEffects`; PvP modes mostly use `hotTicks`.
|
||||||
|
- Stadium applies dampening through profile power callbacks.
|
||||||
|
- PvE cleanse intentionally does not emit floating heal text; PvP modes do.
|
||||||
|
- Shield ratios such as Radiance `0.3`, Mend `0.5`, and Stadium group shield `0.5` belong in profiles.
|
||||||
|
|
||||||
|
When adding a spell rule, prefer adding a named profile field or callback instead of branching in screen components.
|
||||||
|
|
||||||
|
## StackCounts
|
||||||
|
|
||||||
|
Use `createStackCounts` once per party state, upgrade list, or cast snapshot when repeated math needs stack totals. Then pass the map to spell cost, cooldown, target, and effect helpers. This keeps stacked upgrade behavior consistent and avoids repeated `filter`/scan work in combat render and cast paths.
|
||||||
|
|
||||||
|
## Spell Modifiers
|
||||||
|
|
||||||
|
Use `spellModifiers.ts` for stacked upgrade math instead of open-coded exponent formulas in screen components. Screens still own mode-specific IDs, but shared helpers own the math:
|
||||||
|
|
||||||
|
- slot cost/cooldown reducers default to `0.75 ** stacks`.
|
||||||
|
- opponent slot penalties default to `1.25 ** stacks`.
|
||||||
|
- power boosts default to `1.25 ** stacks`.
|
||||||
|
- free-cast checks use a named stack modifier and the mode's existing `freeCastReady` rules.
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
Use `applyCastStateUpdate` after a spell effect profile returns the next party. Screens can still prepare mode-specific cooldown adjustments before the spell cooldown lands, but the final resource spend, cooldown write, party replacement, and free-cast progress should go through the transition helper.
|
||||||
|
|
||||||
|
## PvP Spell Casting
|
||||||
|
|
||||||
|
Use `applyPvpSpellCast` after a PvP screen builds its mode-specific target plan and `SpellEffectProfile`. Keep balance inputs such as roguelike effects, Stadium dampening, shop buffs, cooldown tweaks, and free-cast flags in the screen; let the helper own the repeated validate/apply/emit/write pipeline.
|
||||||
|
|
||||||
|
## CPU AI
|
||||||
|
|
||||||
|
Use `runCpuHealTurn` for PvP CPU turns. Screens still own mode-specific behavior tables and apply-spell adapters, while `cpuAi.ts` owns timing checks, mistake rolls, action selection, and invoking the selected spell.
|
||||||
|
|
||||||
|
## Stadium Lifecycle
|
||||||
|
|
||||||
|
Use `resolveStadiumRound` for win/loss/tie decisions instead of open-coding score checks in `PvpStadiumScreen`. Use `stadiumShopPointsForOutcome` and `chooseStadiumCpuPurchases` for shop setup so player and CPU point rules stay in one place.
|
||||||
|
|
||||||
|
## PvP Roguelike Lifecycle
|
||||||
|
|
||||||
|
Use `resolvePvpRoguelikeCombatOutcome` after each tick advances both sides. Use `resolvePvpRoguelikeLiveSnapshot` when a live match snapshot arrives. Use `nextPvpRoguelikeStage` after upgrade choices to decide whether to continue, advance stage, or complete the match. Components should execute the returned intent with React setters instead of duplicating the decision tree.
|
||||||
|
|
||||||
|
## PvP Roguelike Upgrades
|
||||||
|
|
||||||
|
Use `applyPvpRoguelikeUpgradeChoice` to apply selected buffs, incoming debuffs, and revive-buff debuff cleanup. Use `preparePvpRoguelikeNextEncounter` after progression chooses the next encounter so recovery, 25% resource restore, cooldown reset, and enemy health setup stay consistent between live and CPU matches.
|
||||||
|
|
||||||
|
## PvP Roguelike Match Setup
|
||||||
|
|
||||||
|
Use `createPvpRoguelikeMatchStart` for local/CPU starts and `createPvpRoguelikeLiveMatchStart` for live matches. These helpers build starter sides, set first encounter health, name live opponents, and provide reset defaults so rematch, checkpoint start, and queue fallback do not duplicate initial state construction.
|
||||||
|
|
||||||
|
## Stadium Match Setup
|
||||||
|
|
||||||
|
Use `createStadiumMatchStart` for queue/CPU starts, `createStadiumLiveMatchStart` for live matches, and `createStadiumStarterSide` for advancing to the next round. These helpers reset party state, carry purchased buffs and round wins where needed, and provide shared reset defaults for queue, rematch, and live match starts.
|
||||||
|
|
||||||
|
## PvE Run Setup
|
||||||
|
|
||||||
|
Use `createPveCombatState` for initial single-player combat state and `createPveRoguelikeRunStart` when resetting a run. This keeps party cloning, resource setup, enemy health, cooldown/free-cast reset, and UI reset defaults in one place.
|
||||||
|
|
||||||
|
## Reward Summaries
|
||||||
|
|
||||||
|
Use `createEmptyRewardSummary`, `createEmptyPvpRunSummary`, `mergeDungeonRewardSummary`, and `mergePvpRunRewardSummary` instead of open-coding reward accumulation in screens. This keeps XP, level, talent points, unlocked abilities, and boss loot merge behavior consistent.
|
||||||
|
|
||||||
|
Presentation components for these summaries live in `components/RewardPanels.tsx`: use `RewardXpSummary`, `BonusItemReward`, `LootRollList`, and `PvpRunLootList` instead of duplicating reward JSX in result screens.
|
||||||
|
|
||||||
|
## Dual-Screen Payloads
|
||||||
|
|
||||||
|
Use the builders in `dualScreenPayloads.ts` when publishing combat state to the secondary display. Keep render-derived arrays such as stripped floating text and opponent summaries memoized before calling the builder, then let the builder normalize mode status values like queueing, countdown, and Stadium shop into the bottom-screen status model.
|
||||||
|
|
||||||
|
## Live PvP Sync
|
||||||
|
|
||||||
|
Use `usePvpLiveMatchSync` for live match polling and rematch controls. Screens should provide only mode-specific progress payloads and opponent snapshot rules; the hook owns publish/poll intervals, rematch pending state, expiry, and error messages.
|
||||||
|
|
||||||
|
## Timers
|
||||||
|
|
||||||
|
Use `useRoundCountdown` for PvP round starts and `useDeadlineTimer` for mode deadlines such as PvP roguelike upgrade choice and Stadium shop. Keep countdown expiry behavior mode-specific, but avoid open-coded interval refs and `Date.now()` loops in screens.
|
||||||
|
|
||||||
|
## Floating Combat Text
|
||||||
|
|
||||||
|
Use `useFloatingCombatText` for single-sided combat screens and `useSidedFloatingCombatText` for PvP screens. The hooks own IDs, expiry cleanup, member grouping, side filtering, and dual-screen text stripping so screens only emit heal events.
|
||||||
|
|
||||||
|
## Party Targeting
|
||||||
|
|
||||||
|
Use `usePartyTargeting` for combat-screen navigation wrappers. Keep raw grid/direct selection rules in `combat/targeting.ts`; the hook owns relative, directional, and direct target callbacks around the current party ref, selected-id ref, grid columns, living-target flags, and target group.
|
||||||
|
|
||||||
|
## Render Allocation
|
||||||
|
|
||||||
|
Keep repeated combat-render derivations memoized near their source state: active input bindings, stack summaries, visible shop choices, enemy health segments, and stripped floating text payloads. Avoid calling summary helpers or `bindings[lastDevice]` repeatedly inside JSX loops.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { PartyMember } from '../game'
|
||||||
|
import {
|
||||||
|
clamp,
|
||||||
|
healAmount,
|
||||||
|
memberHotEffects,
|
||||||
|
tickHotEffects,
|
||||||
|
} from './rules'
|
||||||
|
|
||||||
|
export type MemberTickInput = {
|
||||||
|
member: PartyMember
|
||||||
|
party: PartyMember[]
|
||||||
|
damage: number
|
||||||
|
hotHealing: number
|
||||||
|
hotTicks: 'effects' | 'ticks'
|
||||||
|
healingMultiplier?: number
|
||||||
|
damageReductionMultiplier?: number
|
||||||
|
damageReductionRounding?: 'round' | 'ceil'
|
||||||
|
shieldedDamageMultiplier?: number
|
||||||
|
applyDebuff?: {
|
||||||
|
label: string
|
||||||
|
ticks: number
|
||||||
|
}
|
||||||
|
applyPoisonStacks?: boolean
|
||||||
|
poisonDamage?: (stacks: number) => number
|
||||||
|
applyMaxHealthPenaltyTicks?: number
|
||||||
|
applyHealingReductionTicks?: number
|
||||||
|
decrementDamageReduction?: boolean
|
||||||
|
useBounceHeals?: boolean
|
||||||
|
jumpTarget?: () => PartyMember | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MemberTickResult = {
|
||||||
|
member: PartyMember
|
||||||
|
floatingHeal: number
|
||||||
|
jumpedBounceHeals: Array<{
|
||||||
|
targetId: string
|
||||||
|
heal: NonNullable<PartyMember['bounceHeals']>[number]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies one combat tick to a party member. Screen-level engines still decide
|
||||||
|
* who is targeted and when fights end; this helper owns repeated health math:
|
||||||
|
* damage reduction, shields, HoT healing, debuff timers, poison, and bounce heals.
|
||||||
|
*/
|
||||||
|
export function advanceMemberTick({
|
||||||
|
member,
|
||||||
|
party,
|
||||||
|
damage,
|
||||||
|
hotHealing,
|
||||||
|
hotTicks,
|
||||||
|
healingMultiplier = 1,
|
||||||
|
damageReductionMultiplier = 0.5,
|
||||||
|
damageReductionRounding = 'round',
|
||||||
|
shieldedDamageMultiplier,
|
||||||
|
applyDebuff,
|
||||||
|
applyPoisonStacks = false,
|
||||||
|
poisonDamage = () => 0,
|
||||||
|
applyMaxHealthPenaltyTicks,
|
||||||
|
applyHealingReductionTicks,
|
||||||
|
decrementDamageReduction = false,
|
||||||
|
useBounceHeals = false,
|
||||||
|
jumpTarget,
|
||||||
|
}: MemberTickInput): MemberTickResult {
|
||||||
|
if (member.health <= 0) {
|
||||||
|
return { member, floatingHeal: 0, jumpedBounceHeals: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextDamage = damage
|
||||||
|
const nextPoisonStacks = applyPoisonStacks
|
||||||
|
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
||||||
|
: member.poisonStacks ?? 0
|
||||||
|
if (nextPoisonStacks > 0) nextDamage += poisonDamage(nextPoisonStacks)
|
||||||
|
|
||||||
|
if ((member.damageReductionTicks ?? 0) > 0) {
|
||||||
|
const reducedDamage = nextDamage * damageReductionMultiplier
|
||||||
|
nextDamage = damageReductionRounding === 'ceil'
|
||||||
|
? Math.ceil(reducedDamage)
|
||||||
|
: Math.round(reducedDamage)
|
||||||
|
}
|
||||||
|
if (member.shield > 0 && shieldedDamageMultiplier !== undefined) {
|
||||||
|
nextDamage = Math.round(nextDamage * shieldedDamageMultiplier)
|
||||||
|
}
|
||||||
|
|
||||||
|
const absorbed = Math.min(member.shield, nextDamage)
|
||||||
|
const hotEffects = hotTicks === 'effects' ? memberHotEffects(member) : []
|
||||||
|
let healing = hotTicks === 'effects'
|
||||||
|
? hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0)
|
||||||
|
: member.hotTicks > 0 ? healAmount(member, hotHealing, healingMultiplier) : 0
|
||||||
|
|
||||||
|
let nextBounceHeals = [...(member.bounceHeals ?? [])]
|
||||||
|
const jumpedBounceHeals: MemberTickResult['jumpedBounceHeals'] = []
|
||||||
|
if (useBounceHeals && nextDamage > 0 && nextBounceHeals.length > 0) {
|
||||||
|
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
|
||||||
|
healing += healAmount(member, effect.power, healingMultiplier)
|
||||||
|
const nextCharges = effect.charges - 1
|
||||||
|
if (nextCharges <= 0) return []
|
||||||
|
const target = jumpTarget?.() ?? party.find((candidate) => candidate.health > 0 && candidate.id !== member.id) ?? member
|
||||||
|
jumpedBounceHeals.push({
|
||||||
|
targetId: target.id,
|
||||||
|
heal: { ...effect, charges: nextCharges },
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMaxHealthPenaltyTicks = applyMaxHealthPenaltyTicks !== undefined
|
||||||
|
? applyMaxHealthPenaltyTicks
|
||||||
|
: Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1)
|
||||||
|
const nextHealingReductionTicks = applyHealingReductionTicks !== undefined
|
||||||
|
? applyHealingReductionTicks
|
||||||
|
: Math.max(0, (member.healingReductionTicks ?? 0) - 1)
|
||||||
|
const nextDebuffTicks = applyDebuff
|
||||||
|
? applyDebuff.ticks
|
||||||
|
: Math.max(0, (member.debuffTicks ?? 0) - 1)
|
||||||
|
const nextEffectiveMaxHealth = Math.max(1, Math.round(
|
||||||
|
member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1),
|
||||||
|
))
|
||||||
|
|
||||||
|
return {
|
||||||
|
member: {
|
||||||
|
...member,
|
||||||
|
health: clamp(
|
||||||
|
clamp(member.health + healing, 0, nextEffectiveMaxHealth) - nextDamage + absorbed,
|
||||||
|
0,
|
||||||
|
nextEffectiveMaxHealth,
|
||||||
|
),
|
||||||
|
shield: Math.max(0, member.shield - nextDamage),
|
||||||
|
hotTicks: hotTicks === 'effects' ? 0 : Math.max(0, member.hotTicks - 1),
|
||||||
|
hotEffects: hotTicks === 'effects' ? tickHotEffects(hotEffects) : member.hotEffects,
|
||||||
|
bounceHeals: useBounceHeals ? nextBounceHeals : member.bounceHeals,
|
||||||
|
damageReductionTicks: decrementDamageReduction
|
||||||
|
? Math.max(0, (member.damageReductionTicks ?? 0) - 1)
|
||||||
|
: member.damageReductionTicks,
|
||||||
|
debuff: nextDebuffTicks > 0
|
||||||
|
? applyDebuff?.label ?? member.debuff
|
||||||
|
: undefined,
|
||||||
|
debuffTicks: nextDebuffTicks > 0 ? nextDebuffTicks : undefined,
|
||||||
|
poisonStacks: nextPoisonStacks,
|
||||||
|
maxHealthPenaltyTicks: nextMaxHealthPenaltyTicks,
|
||||||
|
healingReductionTicks: nextHealingReductionTicks,
|
||||||
|
},
|
||||||
|
floatingHeal: healing,
|
||||||
|
jumpedBounceHeals,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachJumpedBounceHeals(
|
||||||
|
party: PartyMember[],
|
||||||
|
jumpedBounceHeals: MemberTickResult['jumpedBounceHeals'],
|
||||||
|
) {
|
||||||
|
if (jumpedBounceHeals.length === 0) return party
|
||||||
|
const jumpedByTarget = new Map<string, NonNullable<PartyMember['bounceHeals']>>()
|
||||||
|
for (const jump of jumpedBounceHeals) {
|
||||||
|
const current = jumpedByTarget.get(jump.targetId)
|
||||||
|
if (current) current.push(jump.heal)
|
||||||
|
else jumpedByTarget.set(jump.targetId, [jump.heal])
|
||||||
|
}
|
||||||
|
return party.map((member) => {
|
||||||
|
const jumped = jumpedByTarget.get(member.id)
|
||||||
|
if (!jumped || jumped.length === 0) return member
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
bounceHeals: [
|
||||||
|
...(member.bounceHeals ?? []),
|
||||||
|
...jumped,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { CombatLogEntry } from '../game'
|
||||||
|
|
||||||
|
export type BasicFloatingCombatText = {
|
||||||
|
id: number
|
||||||
|
memberId: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_COMBAT_LOG_LIMIT = 60
|
||||||
|
export const STADIUM_COMBAT_LOG_LIMIT = 70
|
||||||
|
export const DEFAULT_FLOATING_TEXT_LIMIT = 48
|
||||||
|
|
||||||
|
export function appendCombatLog(
|
||||||
|
current: CombatLogEntry[],
|
||||||
|
entry: CombatLogEntry,
|
||||||
|
limit = DEFAULT_COMBAT_LOG_LIMIT,
|
||||||
|
) {
|
||||||
|
return [entry, ...current].slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendFloatingText<T extends BasicFloatingCombatText>(
|
||||||
|
current: T[],
|
||||||
|
entry: T,
|
||||||
|
limit = DEFAULT_FLOATING_TEXT_LIMIT,
|
||||||
|
) {
|
||||||
|
return [...current, entry].slice(-limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupFloatingTextsByMember<T extends BasicFloatingCombatText>(texts: T[]) {
|
||||||
|
const groups = new Map<string, T[]>()
|
||||||
|
texts.forEach((entry) => {
|
||||||
|
const current = groups.get(entry.memberId)
|
||||||
|
if (current) current.push(entry)
|
||||||
|
else groups.set(entry.memberId, [entry])
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripFloatingTextSide<T extends BasicFloatingCombatText>(texts: T[]): BasicFloatingCombatText[] {
|
||||||
|
return texts.map(({ id, memberId, value }) => ({ id, memberId, value }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { PartyMember, Spell } from '../game'
|
||||||
|
import { putSpellOnCooldown } from './spellCasting'
|
||||||
|
import { advanceFreeCastProgress } from './spellEffects'
|
||||||
|
|
||||||
|
export type CastStateFields<TParty extends readonly PartyMember[] = PartyMember[]> = {
|
||||||
|
party: TParty
|
||||||
|
resource: number
|
||||||
|
cooldowns: Record<string, number>
|
||||||
|
castsTowardFree: number
|
||||||
|
freeCastReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyCastStateUpdate<
|
||||||
|
TParty extends readonly PartyMember[],
|
||||||
|
TState extends CastStateFields<TParty>,
|
||||||
|
>({
|
||||||
|
current,
|
||||||
|
party,
|
||||||
|
spell,
|
||||||
|
resourceCost,
|
||||||
|
cooldownMultiplier = 1,
|
||||||
|
cooldowns = current.cooldowns,
|
||||||
|
freeCast = { enabled: false, wasReady: false },
|
||||||
|
}: {
|
||||||
|
current: TState
|
||||||
|
party: TParty
|
||||||
|
spell: Spell
|
||||||
|
resourceCost: number
|
||||||
|
cooldownMultiplier?: number
|
||||||
|
cooldowns?: Record<string, number>
|
||||||
|
freeCast?: {
|
||||||
|
enabled: boolean
|
||||||
|
wasReady: boolean
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
const freeCastProgress = advanceFreeCastProgress({
|
||||||
|
enabled: freeCast.enabled,
|
||||||
|
wasReady: freeCast.wasReady,
|
||||||
|
castsTowardFree: current.castsTowardFree,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
party,
|
||||||
|
resource: current.resource - resourceCost,
|
||||||
|
cooldowns: putSpellOnCooldown(cooldowns, spell, cooldownMultiplier),
|
||||||
|
castsTowardFree: freeCastProgress.castsTowardFree,
|
||||||
|
freeCastReady: freeCastProgress.freeCastReady,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { clamp } from './rules'
|
||||||
|
|
||||||
|
export function tickSeconds(tickMs: number) {
|
||||||
|
return tickMs / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advanceCooldowns(cooldowns: Record<string, number>, elapsedSeconds: number) {
|
||||||
|
const nextCooldowns: Record<string, number> = {}
|
||||||
|
for (const id in cooldowns) {
|
||||||
|
nextCooldowns[id] = Math.max(0, cooldowns[id] - elapsedSeconds)
|
||||||
|
}
|
||||||
|
return nextCooldowns
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regenerateResource(resource: number, amount: number, maxResource: number) {
|
||||||
|
return clamp(resource + amount, 0, maxResource)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { PartyMember, Spell } from '../game'
|
||||||
|
import { effectiveMaxHealth } from './rules'
|
||||||
|
|
||||||
|
export type CpuHealBehavior = {
|
||||||
|
directHealThreshold: number
|
||||||
|
groupHealThreshold: number
|
||||||
|
hotThreshold: number
|
||||||
|
shieldThreshold: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CpuTurnBehavior = CpuHealBehavior & {
|
||||||
|
actionEveryTicks: number
|
||||||
|
mistakeChance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCpuAct({
|
||||||
|
elapsedTicks,
|
||||||
|
behavior,
|
||||||
|
}: {
|
||||||
|
elapsedTicks: number
|
||||||
|
behavior: CpuTurnBehavior
|
||||||
|
}) {
|
||||||
|
return elapsedTicks % behavior.actionEveryTicks === 0 && Math.random() >= behavior.mistakeChance
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chooseCpuHealActions({
|
||||||
|
party,
|
||||||
|
spells,
|
||||||
|
behavior,
|
||||||
|
preferSlots = false,
|
||||||
|
}: {
|
||||||
|
party: PartyMember[]
|
||||||
|
spells: Spell[]
|
||||||
|
behavior: CpuHealBehavior
|
||||||
|
preferSlots?: boolean
|
||||||
|
}) {
|
||||||
|
let livingCount = 0
|
||||||
|
let healthRatioTotal = 0
|
||||||
|
let lowest: PartyMember | null = null
|
||||||
|
let tank: PartyMember | null = null
|
||||||
|
let cleanseTarget: PartyMember | null = null
|
||||||
|
let renewTarget: PartyMember | null = null
|
||||||
|
let shieldTarget: PartyMember | null = null
|
||||||
|
let woundedCount = 0
|
||||||
|
|
||||||
|
for (const member of party) {
|
||||||
|
if (member.health <= 0) continue
|
||||||
|
livingCount += 1
|
||||||
|
const ratio = member.health / effectiveMaxHealth(member)
|
||||||
|
healthRatioTotal += ratio
|
||||||
|
if (!lowest || ratio < lowest.health / effectiveMaxHealth(lowest)) lowest = member
|
||||||
|
if (!tank && member.role === 'Tank') tank = member
|
||||||
|
if (!cleanseTarget && (member.debuff || (member.poisonStacks ?? 0) > 0)) cleanseTarget = member
|
||||||
|
if (!renewTarget && member.hotTicks <= 1 && ratio < behavior.hotThreshold) renewTarget = member
|
||||||
|
if (!shieldTarget && member.role === 'Tank' && member.shield <= 5 && ratio < behavior.shieldThreshold) {
|
||||||
|
shieldTarget = member
|
||||||
|
}
|
||||||
|
if (ratio < behavior.directHealThreshold) woundedCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lowest || livingCount === 0) return []
|
||||||
|
const averageHealth = healthRatioTotal / livingCount
|
||||||
|
const spellByKind = (kind: Spell['kind']) => spells.find((candidate) => candidate.kind === kind)
|
||||||
|
const spellBySlot = (slot: string) => spells.find((candidate) => candidate.key === slot)
|
||||||
|
const direct = preferSlots ? spellBySlot('1') : spellByKind('direct')
|
||||||
|
const hot = preferSlots ? spellBySlot('2') : spellByKind('hot')
|
||||||
|
const group = preferSlots ? spellBySlot('3') : spellByKind('group')
|
||||||
|
const shield = preferSlots ? spellBySlot('4') : spellByKind('shield')
|
||||||
|
const cleanse = preferSlots ? spellBySlot('5') : spellByKind('cleanse')
|
||||||
|
const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [
|
||||||
|
{ spell: cleanseTarget ? cleanse : undefined, targetId: cleanseTarget?.id ?? null },
|
||||||
|
{ spell: averageHealth < behavior.groupHealThreshold ? group : undefined, targetId: lowest.id },
|
||||||
|
{ spell: shieldTarget ? shield : undefined, targetId: shieldTarget?.id ?? null },
|
||||||
|
{ spell: woundedCount > 0 ? direct : undefined, targetId: lowest.id },
|
||||||
|
{ spell: renewTarget ? hot : undefined, targetId: renewTarget?.id ?? null },
|
||||||
|
{ spell: tank ? direct : undefined, targetId: tank?.id ?? null },
|
||||||
|
]
|
||||||
|
|
||||||
|
return ordered
|
||||||
|
.filter((action): action is { spell: Spell; targetId: string } => Boolean(action.spell && action.targetId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chooseCpuHealAction(options: Parameters<typeof chooseCpuHealActions>[0]) {
|
||||||
|
return chooseCpuHealActions(options)[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runCpuHealTurn<TSide>({
|
||||||
|
side,
|
||||||
|
elapsedTicks,
|
||||||
|
spells,
|
||||||
|
behavior,
|
||||||
|
preferSlots = false,
|
||||||
|
applySpell,
|
||||||
|
}: {
|
||||||
|
side: TSide & { party: PartyMember[] }
|
||||||
|
elapsedTicks: number
|
||||||
|
spells: Spell[]
|
||||||
|
behavior: CpuTurnBehavior
|
||||||
|
preferSlots?: boolean
|
||||||
|
applySpell: (side: TSide, spell: Spell, targetId: string) => void
|
||||||
|
}) {
|
||||||
|
if (!shouldCpuAct({ elapsedTicks, behavior })) return false
|
||||||
|
const action = chooseCpuHealAction({
|
||||||
|
party: side.party,
|
||||||
|
spells,
|
||||||
|
behavior,
|
||||||
|
preferSlots,
|
||||||
|
})
|
||||||
|
if (!action) return false
|
||||||
|
applySpell(side, action.spell, action.targetId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { DualScreenCombatState } from '../dualScreen'
|
||||||
|
|
||||||
|
export function buildCombatDualScreenState(state: DualScreenCombatState): DualScreenCombatState {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPvpRoguelikeDualScreenState({
|
||||||
|
status,
|
||||||
|
...state
|
||||||
|
}: Omit<DualScreenCombatState, 'status'> & {
|
||||||
|
status: DualScreenCombatState['status'] | 'queueing' | 'round-countdown'
|
||||||
|
}): DualScreenCombatState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStadiumDualScreenState({
|
||||||
|
status,
|
||||||
|
...state
|
||||||
|
}: Omit<DualScreenCombatState, 'status' | 'targetGroup' | 'speedMultiplier'> & {
|
||||||
|
status: DualScreenCombatState['status'] | 'queueing' | 'round-countdown' | 'shop'
|
||||||
|
}): DualScreenCombatState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
status: status === 'queueing' || status === 'round-countdown'
|
||||||
|
? 'playing'
|
||||||
|
: status === 'shop'
|
||||||
|
? 'upgrade-choice'
|
||||||
|
: status,
|
||||||
|
targetGroup: 0,
|
||||||
|
speedMultiplier: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { DungeonEncounter } from '../profile'
|
||||||
|
import { chooseRandom } from './rules'
|
||||||
|
|
||||||
|
export function encounterThreat(encounter: DungeonEncounter) {
|
||||||
|
return (
|
||||||
|
encounter.maxHealth
|
||||||
|
+ encounter.damage * 18
|
||||||
|
+ encounter.tankDamage * 10
|
||||||
|
+ encounter.partyDamage * 18
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRoguelikeSegment<TMechanic extends string, TExtra extends object = object>({
|
||||||
|
pool,
|
||||||
|
stage,
|
||||||
|
mechanics,
|
||||||
|
trashCandidateCount,
|
||||||
|
bossCandidateCount,
|
||||||
|
healthScale,
|
||||||
|
damageScale,
|
||||||
|
partyDamageScale,
|
||||||
|
idBase,
|
||||||
|
bossDescription,
|
||||||
|
bossName,
|
||||||
|
fallbackBossFirst = false,
|
||||||
|
extraFields,
|
||||||
|
}: {
|
||||||
|
pool: DungeonEncounter[]
|
||||||
|
stage: number
|
||||||
|
mechanics: TMechanic[]
|
||||||
|
trashCandidateCount: (trashCount: number) => number
|
||||||
|
bossCandidateCount: (bossCount: number) => number
|
||||||
|
healthScale: number
|
||||||
|
damageScale: number
|
||||||
|
partyDamageScale: number
|
||||||
|
idBase: number
|
||||||
|
bossDescription: (mechanics: TMechanic[]) => string
|
||||||
|
bossName?: (encounter: DungeonEncounter) => string
|
||||||
|
fallbackBossFirst?: boolean
|
||||||
|
extraFields?: (encounter: DungeonEncounter, isBoss: boolean, mechanics: TMechanic[]) => TExtra
|
||||||
|
}) {
|
||||||
|
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
|
||||||
|
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
||||||
|
const bossPool = [...pool.filter((encounter) => encounter.isBoss)]
|
||||||
|
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
||||||
|
const selectedTrash = chooseRandom(trashPool.slice(0, trashCandidateCount(trashPool.length)), 2)
|
||||||
|
const selectedBoss = chooseRandom(bossPool.slice(0, bossCandidateCount(bossPool.length)), 1)[0]
|
||||||
|
?? (fallbackBossFirst ? bossPool[0] : trashPool[0])
|
||||||
|
?? trashPool[0]
|
||||||
|
?? pool[0]
|
||||||
|
const selectedMechanics = mechanics
|
||||||
|
|
||||||
|
return [...selectedTrash, selectedBoss].map((encounter, index) => {
|
||||||
|
const isBoss = index === 2
|
||||||
|
return {
|
||||||
|
...encounter,
|
||||||
|
id: idBase + stage * 10 + index,
|
||||||
|
sequence: (stage - 1) * 3 + index + 1,
|
||||||
|
isBoss,
|
||||||
|
encounterType: isBoss ? 'boss' : 'trash',
|
||||||
|
enemyName: isBoss ? (bossName?.(encounter) ?? `${encounter.enemyName} ${stage}`) : encounter.enemyName,
|
||||||
|
description: isBoss ? bossDescription(selectedMechanics) : encounter.description,
|
||||||
|
maxHealth: Math.round(encounter.maxHealth * healthScale),
|
||||||
|
damage: Math.round(encounter.damage * damageScale),
|
||||||
|
tankDamage: Math.round(encounter.tankDamage * damageScale),
|
||||||
|
partyDamage: Math.round(encounter.partyDamage * partyDamageScale),
|
||||||
|
lootTables: [],
|
||||||
|
...(extraFields?.(encounter, isBoss, selectedMechanics) ?? {} as TExtra),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { PartyMember } from '../game'
|
||||||
|
|
||||||
|
export type PveCombatState = {
|
||||||
|
party: PartyMember[]
|
||||||
|
resource: number
|
||||||
|
enemyHealth: number
|
||||||
|
cooldowns: Record<string, number>
|
||||||
|
elapsedTicks: number
|
||||||
|
castsTowardFree: number
|
||||||
|
freeCastReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPveCombatState<TEncounter extends { maxHealth: number }>({
|
||||||
|
partyTemplate,
|
||||||
|
maxResource,
|
||||||
|
encounter,
|
||||||
|
enemyCount,
|
||||||
|
}: {
|
||||||
|
partyTemplate: readonly PartyMember[]
|
||||||
|
maxResource: number
|
||||||
|
encounter: TEncounter
|
||||||
|
enemyCount: number
|
||||||
|
}): PveCombatState {
|
||||||
|
return {
|
||||||
|
party: partyTemplate.map((member) => ({ ...member })),
|
||||||
|
resource: maxResource,
|
||||||
|
enemyHealth: encounter.maxHealth * enemyCount,
|
||||||
|
cooldowns: {},
|
||||||
|
elapsedTicks: 0,
|
||||||
|
castsTowardFree: 0,
|
||||||
|
freeCastReady: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPveRoguelikeRunStart<TEncounter extends { maxHealth: number }>({
|
||||||
|
partyTemplate,
|
||||||
|
maxResource,
|
||||||
|
encounters,
|
||||||
|
initialEncounterIndex,
|
||||||
|
enemyCount,
|
||||||
|
}: {
|
||||||
|
partyTemplate: readonly PartyMember[]
|
||||||
|
maxResource: number
|
||||||
|
encounters: readonly TEncounter[]
|
||||||
|
initialEncounterIndex: number
|
||||||
|
enemyCount: number
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
combatState: createPveCombatState({
|
||||||
|
partyTemplate,
|
||||||
|
maxResource,
|
||||||
|
encounter: encounters[initialEncounterIndex],
|
||||||
|
enemyCount,
|
||||||
|
}),
|
||||||
|
defaults: {
|
||||||
|
roguelikeStage: 1,
|
||||||
|
selectedId: partyTemplate[0].id,
|
||||||
|
encounterIndex: initialEncounterIndex,
|
||||||
|
status: 'playing' as const,
|
||||||
|
paused: false,
|
||||||
|
targetGroup: 0 as const,
|
||||||
|
reward: null,
|
||||||
|
rewardError: '',
|
||||||
|
lootRolls: [],
|
||||||
|
showEndLog: false,
|
||||||
|
floatingTexts: [],
|
||||||
|
roguelikeUpgrades: [],
|
||||||
|
upgradeChoices: [],
|
||||||
|
marathonBossesDefeated: 0,
|
||||||
|
resourceSpent: 0,
|
||||||
|
log: { text: 'A new run begins.', tone: 'system' as const },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
export type PvpRoguelikeStatus = 'queueing' | 'round-countdown' | 'playing' | 'upgrade-choice' | 'won' | 'lost'
|
||||||
|
export type PvpRoguelikeLogTone = 'system' | 'heal' | 'danger' | 'loot'
|
||||||
|
|
||||||
|
export type PvpRoguelikeCombatOutcome =
|
||||||
|
| {
|
||||||
|
type: 'none'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'lost'
|
||||||
|
status: 'lost'
|
||||||
|
log: { text: string; tone: PvpRoguelikeLogTone }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'won'
|
||||||
|
status: 'won'
|
||||||
|
markOpponentDefeated: boolean
|
||||||
|
log: { text: string; tone: PvpRoguelikeLogTone }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'upgrade-choice'
|
||||||
|
status: 'upgrade-choice'
|
||||||
|
log: { text: string; tone: PvpRoguelikeLogTone }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePvpRoguelikeCombatOutcome({
|
||||||
|
playerAlive,
|
||||||
|
opponentAlive,
|
||||||
|
playerCleared,
|
||||||
|
encounterIsBoss,
|
||||||
|
encounterName,
|
||||||
|
opponentDefeated,
|
||||||
|
liveMatchActive,
|
||||||
|
opponentLabel,
|
||||||
|
}: {
|
||||||
|
playerAlive: boolean
|
||||||
|
opponentAlive: boolean
|
||||||
|
playerCleared: boolean
|
||||||
|
encounterIsBoss: boolean
|
||||||
|
encounterName: string
|
||||||
|
opponentDefeated: boolean
|
||||||
|
liveMatchActive: boolean
|
||||||
|
opponentLabel: string
|
||||||
|
}): PvpRoguelikeCombatOutcome {
|
||||||
|
if (!playerAlive) {
|
||||||
|
return {
|
||||||
|
type: 'lost',
|
||||||
|
status: 'lost',
|
||||||
|
log: { text: 'Your party fell first.', tone: 'danger' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!liveMatchActive && !opponentAlive && !opponentDefeated) {
|
||||||
|
return {
|
||||||
|
type: 'won',
|
||||||
|
status: 'won',
|
||||||
|
markOpponentDefeated: true,
|
||||||
|
log: { text: `${opponentLabel} fell. Match complete.`, tone: 'loot' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!playerCleared) return { type: 'none' }
|
||||||
|
if (encounterIsBoss && opponentDefeated) {
|
||||||
|
return {
|
||||||
|
type: 'won',
|
||||||
|
status: 'won',
|
||||||
|
markOpponentDefeated: false,
|
||||||
|
log: { text: `${opponentLabel} defeated. Match complete.`, tone: 'loot' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'upgrade-choice',
|
||||||
|
status: 'upgrade-choice',
|
||||||
|
log: { text: `${encounterName} cleared. Choose your next edge.`, tone: 'loot' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePvpRoguelikeLiveSnapshot({
|
||||||
|
currentStatus,
|
||||||
|
opponentStatus,
|
||||||
|
opponentAlive,
|
||||||
|
alreadyLoggedOpponentDone,
|
||||||
|
opponentName,
|
||||||
|
}: {
|
||||||
|
currentStatus: PvpRoguelikeStatus
|
||||||
|
opponentStatus?: string
|
||||||
|
opponentAlive?: boolean
|
||||||
|
alreadyLoggedOpponentDone: boolean
|
||||||
|
opponentName: string
|
||||||
|
}) {
|
||||||
|
if (currentStatus === 'won' || currentStatus === 'lost') return { type: 'none' as const }
|
||||||
|
if ((opponentStatus === 'lost' || opponentAlive === false) && !alreadyLoggedOpponentDone) {
|
||||||
|
return {
|
||||||
|
type: 'won' as const,
|
||||||
|
status: 'won' as const,
|
||||||
|
markOpponentDefeated: true,
|
||||||
|
log: { text: `${opponentName} fell. Match complete.`, tone: 'loot' as const },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opponentStatus === 'won') {
|
||||||
|
return {
|
||||||
|
type: 'lost' as const,
|
||||||
|
status: 'lost' as const,
|
||||||
|
log: { text: `${opponentName} finished first.`, tone: 'danger' as const },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: 'none' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePvpRoguelikeUpgradeCompletion({
|
||||||
|
clearedBoss,
|
||||||
|
opponentDefeated,
|
||||||
|
opponentLabel,
|
||||||
|
}: {
|
||||||
|
clearedBoss: boolean
|
||||||
|
opponentDefeated: boolean
|
||||||
|
opponentLabel: string
|
||||||
|
}) {
|
||||||
|
if (!clearedBoss || !opponentDefeated) return { type: 'none' as const }
|
||||||
|
return {
|
||||||
|
type: 'won' as const,
|
||||||
|
status: 'won' as const,
|
||||||
|
log: { text: `${opponentLabel} defeated. Match complete.`, tone: 'loot' as const },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextPvpRoguelikeStage<TEncounter>({
|
||||||
|
clearedBoss,
|
||||||
|
stage,
|
||||||
|
encounterIndex,
|
||||||
|
encounters,
|
||||||
|
nextSegment,
|
||||||
|
}: {
|
||||||
|
clearedBoss: boolean
|
||||||
|
stage: number
|
||||||
|
encounterIndex: number
|
||||||
|
encounters: readonly TEncounter[]
|
||||||
|
nextSegment: readonly TEncounter[]
|
||||||
|
}) {
|
||||||
|
const nextStage = clearedBoss ? stage + 1 : stage
|
||||||
|
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
||||||
|
if (!nextEncounter) {
|
||||||
|
return {
|
||||||
|
type: 'complete' as const,
|
||||||
|
status: 'won' as const,
|
||||||
|
log: { text: 'No further encounters remain.', tone: 'loot' as const },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'next' as const,
|
||||||
|
nextStage,
|
||||||
|
nextEncounter,
|
||||||
|
nextEncounterIndex: encounterIndex + 1,
|
||||||
|
appendSegment: clearedBoss,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { PartyMember } from '../game'
|
||||||
|
import type { PvpMatchSnapshot, PvpMatchSide } from '../pvpRoguelike'
|
||||||
|
|
||||||
|
export type PvpRoguelikeSetupSide<TBuff extends string, TDebuff extends string> = {
|
||||||
|
party: PartyMember[]
|
||||||
|
resource: number
|
||||||
|
cooldowns: Record<string, number>
|
||||||
|
enemyHealth: number
|
||||||
|
buffs: TBuff[]
|
||||||
|
debuffs: TDebuff[]
|
||||||
|
castsTowardFree: number
|
||||||
|
freeCastReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PvpRoguelikeLiveMatchSetup = {
|
||||||
|
id: string
|
||||||
|
side: PvpMatchSide
|
||||||
|
opponentSide: PvpMatchSide
|
||||||
|
opponentName: string
|
||||||
|
opponentClassName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPvpRoguelikeStarterSide<TBuff extends string, TDebuff extends string>(
|
||||||
|
partyTemplate: readonly PartyMember[],
|
||||||
|
maxResource: number,
|
||||||
|
): PvpRoguelikeSetupSide<TBuff, TDebuff> {
|
||||||
|
return {
|
||||||
|
party: partyTemplate.map((member) => ({ ...member })),
|
||||||
|
resource: maxResource,
|
||||||
|
cooldowns: {},
|
||||||
|
enemyHealth: 0,
|
||||||
|
buffs: [],
|
||||||
|
debuffs: [],
|
||||||
|
castsTowardFree: 0,
|
||||||
|
freeCastReady: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPvpRoguelikeMatchStart<
|
||||||
|
TBuff extends string,
|
||||||
|
TDebuff extends string,
|
||||||
|
TEncounter extends { maxHealth: number },
|
||||||
|
>({
|
||||||
|
startStage,
|
||||||
|
encounterPool,
|
||||||
|
buildSegment,
|
||||||
|
partyTemplate,
|
||||||
|
opponentPartyTemplate,
|
||||||
|
maxResource,
|
||||||
|
upgradeChoiceSeconds,
|
||||||
|
}: {
|
||||||
|
startStage: number
|
||||||
|
encounterPool: readonly TEncounter[]
|
||||||
|
buildSegment: (pool: readonly TEncounter[], stage: number) => TEncounter[]
|
||||||
|
partyTemplate: readonly PartyMember[]
|
||||||
|
opponentPartyTemplate: readonly PartyMember[]
|
||||||
|
maxResource: number
|
||||||
|
upgradeChoiceSeconds: number
|
||||||
|
}) {
|
||||||
|
const firstSegment = buildSegment(encounterPool, startStage)
|
||||||
|
const firstEncounter = firstSegment[0]
|
||||||
|
const playerSide = createPvpRoguelikeStarterSide<TBuff, TDebuff>(partyTemplate, maxResource)
|
||||||
|
const opponentSide = createPvpRoguelikeStarterSide<TBuff, TDebuff>(opponentPartyTemplate, maxResource)
|
||||||
|
playerSide.enemyHealth = firstEncounter.maxHealth
|
||||||
|
opponentSide.enemyHealth = firstEncounter.maxHealth
|
||||||
|
|
||||||
|
return {
|
||||||
|
startStage,
|
||||||
|
firstSegment,
|
||||||
|
firstEncounter,
|
||||||
|
playerSide,
|
||||||
|
opponentSide,
|
||||||
|
defaults: {
|
||||||
|
encounterIndex: 0,
|
||||||
|
elapsedTicks: 0,
|
||||||
|
encountersCleared: 0,
|
||||||
|
paused: false,
|
||||||
|
targetGroup: 0 as const,
|
||||||
|
upgradeTimeLeft: upgradeChoiceSeconds,
|
||||||
|
liveUpgradePending: false,
|
||||||
|
rewardError: '',
|
||||||
|
showEndLog: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPvpRoguelikeLiveMatchStart<
|
||||||
|
TBuff extends string,
|
||||||
|
TDebuff extends string,
|
||||||
|
TEncounter extends { maxHealth: number },
|
||||||
|
>({
|
||||||
|
match,
|
||||||
|
side,
|
||||||
|
encounterPool,
|
||||||
|
buildSegment,
|
||||||
|
partyTemplate,
|
||||||
|
opponentPartyTemplate,
|
||||||
|
maxResource,
|
||||||
|
upgradeChoiceSeconds,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
match: PvpMatchSnapshot<PvpRoguelikeSetupSide<TBuff, TDebuff>>
|
||||||
|
side: PvpMatchSide
|
||||||
|
encounterPool: readonly TEncounter[]
|
||||||
|
buildSegment: (pool: readonly TEncounter[], stage: number) => TEncounter[]
|
||||||
|
partyTemplate: readonly PartyMember[]
|
||||||
|
opponentPartyTemplate: readonly PartyMember[]
|
||||||
|
maxResource: number
|
||||||
|
upgradeChoiceSeconds: number
|
||||||
|
message?: string
|
||||||
|
}) {
|
||||||
|
const opponentSideId: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
||||||
|
const opponent = match.players[opponentSideId]
|
||||||
|
const opponentTemplate = opponentPartyTemplate.map((member) => ({
|
||||||
|
...member,
|
||||||
|
name: member.id === 'mira' ? opponent.characterName : member.name,
|
||||||
|
}))
|
||||||
|
const setup = createPvpRoguelikeMatchStart<TBuff, TDebuff, TEncounter>({
|
||||||
|
startStage: match.startStage,
|
||||||
|
encounterPool,
|
||||||
|
buildSegment,
|
||||||
|
partyTemplate,
|
||||||
|
opponentPartyTemplate: opponentTemplate,
|
||||||
|
maxResource,
|
||||||
|
upgradeChoiceSeconds,
|
||||||
|
})
|
||||||
|
const liveMatch: PvpRoguelikeLiveMatchSetup = {
|
||||||
|
id: match.id,
|
||||||
|
side,
|
||||||
|
opponentSide: opponentSideId,
|
||||||
|
opponentName: opponent.characterName,
|
||||||
|
opponentClassName: opponent.className,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...setup,
|
||||||
|
liveMatch,
|
||||||
|
logText: message ?? `${opponent.characterName} found. Stage ${match.startStage} begins.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { PartyMember } from '../game'
|
||||||
|
|
||||||
|
export type PvpRoguelikeUpgradeSide<TBuff extends string, TDebuff extends string> = {
|
||||||
|
party: PartyMember[]
|
||||||
|
resource: number
|
||||||
|
cooldowns: Record<string, number>
|
||||||
|
enemyHealth: number
|
||||||
|
buffs: TBuff[]
|
||||||
|
debuffs: TDebuff[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRandomItem<T>(items: readonly T[], random = Math.random) {
|
||||||
|
if (items.length === 0) return [...items]
|
||||||
|
const removedIndex = Math.floor(random() * items.length)
|
||||||
|
return items.filter((_, index) => index !== removedIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recoverPartyForNextPvpRoguelikeEncounter(party: readonly PartyMember[]) {
|
||||||
|
return party.map((member) => ({
|
||||||
|
...member,
|
||||||
|
health: member.maxHealth,
|
||||||
|
debuff: undefined,
|
||||||
|
debuffTicks: undefined,
|
||||||
|
poisonStacks: undefined,
|
||||||
|
maxHealthPenaltyTicks: undefined,
|
||||||
|
healingReductionTicks: undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPvpRoguelikeUpgradeChoice<
|
||||||
|
TBuff extends string,
|
||||||
|
TDebuff extends string,
|
||||||
|
TSide extends PvpRoguelikeUpgradeSide<TBuff, TDebuff>,
|
||||||
|
>({
|
||||||
|
side,
|
||||||
|
buffId,
|
||||||
|
incomingDebuffId,
|
||||||
|
reviveBuffId,
|
||||||
|
random,
|
||||||
|
}: {
|
||||||
|
side: TSide
|
||||||
|
buffId: TBuff
|
||||||
|
incomingDebuffId?: TDebuff
|
||||||
|
reviveBuffId: TBuff
|
||||||
|
random?: () => number
|
||||||
|
}) {
|
||||||
|
const reviveSelected = buffId === reviveBuffId
|
||||||
|
const nextBuffs = reviveSelected ? side.buffs : [...side.buffs, buffId]
|
||||||
|
const nextDebuffs = incomingDebuffId ? [...side.debuffs, incomingDebuffId] : side.debuffs
|
||||||
|
return {
|
||||||
|
...side,
|
||||||
|
buffs: nextBuffs,
|
||||||
|
debuffs: reviveSelected ? removeRandomItem(nextDebuffs, random) : nextDebuffs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preparePvpRoguelikeNextEncounter<
|
||||||
|
TBuff extends string,
|
||||||
|
TDebuff extends string,
|
||||||
|
TSide extends PvpRoguelikeUpgradeSide<TBuff, TDebuff>,
|
||||||
|
>({
|
||||||
|
side,
|
||||||
|
maxResource,
|
||||||
|
enemyMaxHealth,
|
||||||
|
}: {
|
||||||
|
side: TSide
|
||||||
|
maxResource: number
|
||||||
|
enemyMaxHealth: number
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
...side,
|
||||||
|
party: recoverPartyForNextPvpRoguelikeEncounter(side.party),
|
||||||
|
resource: maxResource,
|
||||||
|
cooldowns: {},
|
||||||
|
enemyHealth: enemyMaxHealth,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { PartyMember, Spell } from '../game'
|
||||||
|
import { applyCastStateUpdate, type CastStateFields } from './combatStateTransitions'
|
||||||
|
import { canCastSpell } from './spellCasting'
|
||||||
|
import { applySpellEffectProfile, type SpellEffectProfile, type SpellTargetPlan } from './spellEffects'
|
||||||
|
|
||||||
|
export function applyPvpSpellCast<TState extends CastStateFields<PartyMember[]>>({
|
||||||
|
current,
|
||||||
|
spell,
|
||||||
|
targetId,
|
||||||
|
resourceCost,
|
||||||
|
targetPlan,
|
||||||
|
profile,
|
||||||
|
setCurrent,
|
||||||
|
emitFloatingHeal,
|
||||||
|
cooldownMultiplier = 1,
|
||||||
|
cooldowns,
|
||||||
|
freeCast,
|
||||||
|
}: {
|
||||||
|
current: TState
|
||||||
|
spell: Spell
|
||||||
|
targetId: string
|
||||||
|
resourceCost: number
|
||||||
|
targetPlan: SpellTargetPlan
|
||||||
|
profile: SpellEffectProfile
|
||||||
|
setCurrent: (next: TState) => void
|
||||||
|
emitFloatingHeal: (memberId: string, value: number) => void
|
||||||
|
cooldownMultiplier?: number
|
||||||
|
cooldowns?: Record<string, number>
|
||||||
|
freeCast?: {
|
||||||
|
enabled: boolean
|
||||||
|
wasReady: boolean
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
if (!canCastSpell(spell, current.resource, current.cooldowns, resourceCost)) return false
|
||||||
|
const target = current.party.find((member) => member.id === targetId && member.health > 0)
|
||||||
|
if (!target) return false
|
||||||
|
|
||||||
|
const { party: nextParty, floatingHeals } = applySpellEffectProfile({
|
||||||
|
party: current.party,
|
||||||
|
spell,
|
||||||
|
targetId,
|
||||||
|
plan: targetPlan,
|
||||||
|
profile,
|
||||||
|
})
|
||||||
|
floatingHeals.forEach((event) => emitFloatingHeal(event.memberId, event.value))
|
||||||
|
|
||||||
|
const nextState = applyCastStateUpdate({
|
||||||
|
current,
|
||||||
|
party: nextParty,
|
||||||
|
cooldowns,
|
||||||
|
spell,
|
||||||
|
resourceCost,
|
||||||
|
cooldownMultiplier,
|
||||||
|
freeCast,
|
||||||
|
})
|
||||||
|
setCurrent(nextState)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { DungeonReward } from '../profile'
|
||||||
|
|
||||||
|
export type RewardSummaryBase = {
|
||||||
|
experienceGained: number
|
||||||
|
previousLevel: number | null
|
||||||
|
newLevel: number | null
|
||||||
|
levelsGained: number
|
||||||
|
talentPointsGained: number
|
||||||
|
unlockedAbilities: DungeonReward['unlockedAbilities']
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PvpRunRewardSummary = RewardSummaryBase & {
|
||||||
|
bossesKilled: number
|
||||||
|
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyRewardSummary(): RewardSummaryBase {
|
||||||
|
return {
|
||||||
|
experienceGained: 0,
|
||||||
|
previousLevel: null,
|
||||||
|
newLevel: null,
|
||||||
|
levelsGained: 0,
|
||||||
|
talentPointsGained: 0,
|
||||||
|
unlockedAbilities: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyPvpRunSummary(): PvpRunRewardSummary {
|
||||||
|
return {
|
||||||
|
bossesKilled: 0,
|
||||||
|
...createEmptyRewardSummary(),
|
||||||
|
loot: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeUnlockedAbilities(
|
||||||
|
current: DungeonReward['unlockedAbilities'],
|
||||||
|
next: DungeonReward['unlockedAbilities'],
|
||||||
|
) {
|
||||||
|
const unlockedById = new Map(current.map((ability) => [ability.id, ability]))
|
||||||
|
next.forEach((ability) => unlockedById.set(ability.id, ability))
|
||||||
|
return Array.from(unlockedById.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeDungeonRewardSummary<TSummary extends RewardSummaryBase>(
|
||||||
|
current: TSummary,
|
||||||
|
reward: DungeonReward,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
experienceGained: current.experienceGained + reward.experienceGained,
|
||||||
|
previousLevel: current.previousLevel ?? reward.previousLevel,
|
||||||
|
newLevel: reward.newLevel,
|
||||||
|
levelsGained: current.levelsGained + reward.levelsGained,
|
||||||
|
talentPointsGained: current.talentPointsGained + reward.talentPointsGained,
|
||||||
|
unlockedAbilities: mergeUnlockedAbilities(current.unlockedAbilities, reward.unlockedAbilities),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergePvpRunRewardSummary(
|
||||||
|
current: PvpRunRewardSummary,
|
||||||
|
reward: DungeonReward,
|
||||||
|
{ bossKilled }: { bossKilled: boolean },
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...mergeDungeonRewardSummary(current, reward),
|
||||||
|
bossesKilled: current.bossesKilled + (bossKilled ? 1 : 0),
|
||||||
|
loot: reward.bonusItem ? [...current.loot, reward.bonusItem] : current.loot,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { Spell } from '../game'
|
||||||
|
import { createStackCounts, summarizeStackCounts } from './stackCounts'
|
||||||
|
|
||||||
|
export type UpgradeChoice<T extends string> = {
|
||||||
|
id: T
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slotLabel(slot: string, spells: Spell[], labelMode: 'ability' | 'slot') {
|
||||||
|
const spell = spells.find((candidate) => candidate.key === slot)
|
||||||
|
if (labelMode === 'ability' && spell) return spell.name
|
||||||
|
return `Slot ${slot}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSelfSlotUpgradeChoices<T extends string>({
|
||||||
|
slots,
|
||||||
|
spells,
|
||||||
|
labelMode,
|
||||||
|
idPrefix = 'slot',
|
||||||
|
}: {
|
||||||
|
slots: readonly string[]
|
||||||
|
spells: Spell[]
|
||||||
|
labelMode: 'ability' | 'slot'
|
||||||
|
idPrefix?: string
|
||||||
|
}) {
|
||||||
|
return slots.flatMap((slot) => {
|
||||||
|
const label = slotLabel(slot, spells, labelMode)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `${idPrefix}${slot}-extra-target` as T,
|
||||||
|
name: `${label}: +1 target`,
|
||||||
|
description: `${label} affects 1 additional ally when possible.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${idPrefix}${slot}-cost-down` as T,
|
||||||
|
name: `${label}: -25% cost`,
|
||||||
|
description: `${label} costs 25% less resource.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${idPrefix}${slot}-cooldown-down` as T,
|
||||||
|
name: `${label}: -25% cooldown`,
|
||||||
|
description: `${label} recharges 25% faster.`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpponentSlotDebuffChoices<T extends string>({
|
||||||
|
slots,
|
||||||
|
spells,
|
||||||
|
labelMode,
|
||||||
|
}: {
|
||||||
|
slots: readonly string[]
|
||||||
|
spells: Spell[]
|
||||||
|
labelMode: 'ability' | 'slot'
|
||||||
|
}) {
|
||||||
|
return slots.flatMap((slot) => {
|
||||||
|
const label = slotLabel(slot, spells, labelMode)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `opp-slot${slot}-cost-up` as T,
|
||||||
|
name: `${label}: +25% cost`,
|
||||||
|
description: `Opponent ${label.toLowerCase()} costs 25% more resource.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `opp-slot${slot}-cooldown-up` as T,
|
||||||
|
name: `${label}: +25% cooldown`,
|
||||||
|
description: `Opponent ${label.toLowerCase()} recharges 25% slower.`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeChoiceStacks<T extends string>(
|
||||||
|
items: T[],
|
||||||
|
catalog: Array<UpgradeChoice<T>>,
|
||||||
|
emptyLabel = '',
|
||||||
|
) {
|
||||||
|
return summarizeStackCounts(createStackCounts(items), catalog, emptyLabel)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import type { PartyMember, Spell } from '../game'
|
||||||
|
import type { Ability } from '../profile'
|
||||||
|
import { createStackCounts, stackCount } from './stackCounts'
|
||||||
|
|
||||||
|
export const DEFAULT_TICK_MS = 700
|
||||||
|
|
||||||
|
export function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chooseRandom<T>(items: T[], count: number) {
|
||||||
|
const pool = [...items]
|
||||||
|
const result: T[] = []
|
||||||
|
while (pool.length > 0 && result.length < count) {
|
||||||
|
const index = Math.floor(Math.random() * pool.length)
|
||||||
|
result.push(pool.splice(index, 1)[0])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveMaxHealth(member: PartyMember) {
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healAmount(member: PartyMember, amount: number, multiplier = 1) {
|
||||||
|
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healMember(member: PartyMember, amount: number, multiplier = 1) {
|
||||||
|
return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memberHotEffects(member: PartyMember) {
|
||||||
|
if (member.hotEffects?.length) return member.hotEffects
|
||||||
|
return member.hotTicks > 0
|
||||||
|
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectId(prefix: string) {
|
||||||
|
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
|
||||||
|
const nextEffect = {
|
||||||
|
id: effectId(spell.id),
|
||||||
|
spellId: spell.id,
|
||||||
|
label: spell.name,
|
||||||
|
ticks,
|
||||||
|
power: Math.max(1, Math.round(spell.power / 2)),
|
||||||
|
}
|
||||||
|
const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id)
|
||||||
|
return [...currentEffects, nextEffect]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addBounceHeal(member: PartyMember, spell: Spell) {
|
||||||
|
return [
|
||||||
|
...(member.bounceHeals ?? []),
|
||||||
|
{
|
||||||
|
id: effectId(spell.id),
|
||||||
|
label: spell.name,
|
||||||
|
charges: 4,
|
||||||
|
power: spell.power,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tickHotEffects(effects: PartyMember['hotEffects']) {
|
||||||
|
return (effects ?? [])
|
||||||
|
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
|
||||||
|
.filter((effect) => effect.ticks > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEffectTime(ticks: number, tickMs = DEFAULT_TICK_MS) {
|
||||||
|
const seconds = (ticks * tickMs) / 1000
|
||||||
|
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buffStacks<T extends string>(items: readonly T[], id: T) {
|
||||||
|
return stackCount(createStackCounts(items), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogEntry(nextLogId: { current: number }, text: string, tone: 'system' | 'heal' | 'danger' | 'loot') {
|
||||||
|
return { id: nextLogId.current++, text, tone }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCombatSpell(ability: Ability, key: string, healingPower = 0): Spell {
|
||||||
|
const kinds: Record<string, Spell['kind']> = {
|
||||||
|
direct_heal: 'direct',
|
||||||
|
direct_hot: 'direct',
|
||||||
|
heal_over_time: 'hot',
|
||||||
|
party_heal: 'group',
|
||||||
|
party_hot: 'group',
|
||||||
|
party_absorb: 'group',
|
||||||
|
absorb: 'shield',
|
||||||
|
damage_reduction: 'damage_reduction',
|
||||||
|
bounce_heal: 'bounce_heal',
|
||||||
|
cleanse: 'cleanse',
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(ability.id),
|
||||||
|
key,
|
||||||
|
name: ability.name,
|
||||||
|
description: ability.description,
|
||||||
|
cost: ability.cost,
|
||||||
|
cooldown: ability.cooldown,
|
||||||
|
power: ability.power + healingPower,
|
||||||
|
glyph: ability.glyph,
|
||||||
|
kind: kinds[ability.spellType] ?? 'direct',
|
||||||
|
effectType: ability.spellType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetParty(partyTemplate: PartyMember[]) {
|
||||||
|
return partyTemplate.map((member) => ({
|
||||||
|
...member,
|
||||||
|
health: member.maxHealth,
|
||||||
|
shield: 0,
|
||||||
|
hotTicks: 0,
|
||||||
|
hotEffects: undefined,
|
||||||
|
debuff: undefined,
|
||||||
|
debuffTicks: undefined,
|
||||||
|
poisonStacks: undefined,
|
||||||
|
maxHealthPenaltyTicks: undefined,
|
||||||
|
healingReductionTicks: undefined,
|
||||||
|
damageReductionTicks: undefined,
|
||||||
|
bounceHeals: undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { Spell } from '../game'
|
||||||
|
|
||||||
|
export function cooldownNow() {
|
||||||
|
return Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cooldownRemaining(
|
||||||
|
cooldowns: Record<string, number>,
|
||||||
|
spellId: string,
|
||||||
|
now = cooldownNow(),
|
||||||
|
) {
|
||||||
|
return Math.max(0, ((cooldowns[spellId] ?? 0) - now) / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasActiveCooldowns(
|
||||||
|
cooldowns: Record<string, number>,
|
||||||
|
now = cooldownNow(),
|
||||||
|
) {
|
||||||
|
return Object.values(cooldowns).some((readyAtMs) => readyAtMs > now)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneExpiredCooldowns(
|
||||||
|
cooldowns: Record<string, number>,
|
||||||
|
now = cooldownNow(),
|
||||||
|
) {
|
||||||
|
let changed = false
|
||||||
|
const nextCooldowns: Record<string, number> = {}
|
||||||
|
for (const id in cooldowns) {
|
||||||
|
if (cooldowns[id] <= now) {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextCooldowns[id] = cooldowns[id]
|
||||||
|
}
|
||||||
|
return changed ? nextCooldowns : cooldowns
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduceCooldown(
|
||||||
|
cooldowns: Record<string, number>,
|
||||||
|
spellId: string,
|
||||||
|
seconds: number,
|
||||||
|
now = cooldownNow(),
|
||||||
|
) {
|
||||||
|
const readyAtMs = cooldowns[spellId]
|
||||||
|
if (!readyAtMs || readyAtMs <= now) return cooldowns
|
||||||
|
const nextReadyAtMs = readyAtMs - seconds * 1000
|
||||||
|
const nextCooldowns = { ...cooldowns }
|
||||||
|
if (nextReadyAtMs <= now) delete nextCooldowns[spellId]
|
||||||
|
else nextCooldowns[spellId] = nextReadyAtMs
|
||||||
|
return nextCooldowns
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canCastSpell(
|
||||||
|
spell: Spell,
|
||||||
|
resource: number,
|
||||||
|
cooldowns: Record<string, number>,
|
||||||
|
resourceCost: number,
|
||||||
|
now = cooldownNow(),
|
||||||
|
) {
|
||||||
|
return resource >= resourceCost && cooldownRemaining(cooldowns, spell.id, now) <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function putSpellOnCooldown(
|
||||||
|
cooldowns: Record<string, number>,
|
||||||
|
spell: Spell,
|
||||||
|
cooldownMultiplier = 1,
|
||||||
|
now = cooldownNow(),
|
||||||
|
) {
|
||||||
|
const cooldownMs = spell.cooldown * cooldownMultiplier * 1000
|
||||||
|
if (cooldownMs <= 0) {
|
||||||
|
const nextCooldowns = { ...cooldowns }
|
||||||
|
delete nextCooldowns[spell.id]
|
||||||
|
return nextCooldowns
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...cooldowns,
|
||||||
|
[spell.id]: now + cooldownMs,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import { DEFAULT_GROUP_HEAL_TARGETS, groupHealTargets, type PartyMember, type Spell } from '../game'
|
||||||
|
import { addBounceHeal, addHotEffect } from './rules'
|
||||||
|
|
||||||
|
export type SpellTargetBucket = 'direct' | 'hot' | 'shield' | 'damageReduction'
|
||||||
|
|
||||||
|
export type SpellTargetPlan = {
|
||||||
|
directTargets: Set<string>
|
||||||
|
hotTargets: Set<string>
|
||||||
|
shieldTargets: Set<string>
|
||||||
|
damageReductionTargets: Set<string>
|
||||||
|
groupTargets: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtraTargetMode = Partial<Record<Spell['kind'], SpellTargetBucket>>
|
||||||
|
|
||||||
|
function hasBlockedId(blockedIds: Iterable<string>, id: string) {
|
||||||
|
if (blockedIds instanceof Set) return blockedIds.has(id)
|
||||||
|
for (const blockedId of blockedIds) {
|
||||||
|
if (blockedId === id) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lowestHealthExtraTarget(party: PartyMember[], blockedIds: Iterable<string>) {
|
||||||
|
let target: PartyMember | undefined
|
||||||
|
let targetRatio = Number.POSITIVE_INFINITY
|
||||||
|
for (const member of party) {
|
||||||
|
if (member.health <= 0 || hasBlockedId(blockedIds, member.id)) continue
|
||||||
|
const healthRatio = member.health / member.maxHealth
|
||||||
|
if (healthRatio < targetRatio) {
|
||||||
|
target = member
|
||||||
|
targetRatio = healthRatio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addLowestHealthExtraTarget(
|
||||||
|
plan: SpellTargetPlan,
|
||||||
|
party: PartyMember[],
|
||||||
|
bucket: SpellTargetBucket,
|
||||||
|
) {
|
||||||
|
const targetSet = bucket === 'direct'
|
||||||
|
? plan.directTargets
|
||||||
|
: bucket === 'hot'
|
||||||
|
? plan.hotTargets
|
||||||
|
: bucket === 'shield'
|
||||||
|
? plan.shieldTargets
|
||||||
|
: plan.damageReductionTargets
|
||||||
|
const extra = lowestHealthExtraTarget(party, targetSet)
|
||||||
|
if (extra) targetSet.add(extra.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSpellTargetPlan({
|
||||||
|
party,
|
||||||
|
spell,
|
||||||
|
targetId,
|
||||||
|
extraTargets,
|
||||||
|
directTarget,
|
||||||
|
hotTarget,
|
||||||
|
shieldTarget,
|
||||||
|
damageReductionTarget,
|
||||||
|
groupTargetCount = DEFAULT_GROUP_HEAL_TARGETS,
|
||||||
|
extraTargetMode,
|
||||||
|
beforeExtraTargetBuckets = [],
|
||||||
|
}: {
|
||||||
|
party: PartyMember[]
|
||||||
|
spell: Spell
|
||||||
|
targetId: string
|
||||||
|
extraTargets: number
|
||||||
|
directTarget: boolean
|
||||||
|
hotTarget: boolean
|
||||||
|
shieldTarget: boolean
|
||||||
|
damageReductionTarget?: boolean
|
||||||
|
groupTargetCount?: number
|
||||||
|
extraTargetMode: ExtraTargetMode
|
||||||
|
beforeExtraTargetBuckets?: SpellTargetBucket[]
|
||||||
|
}): SpellTargetPlan {
|
||||||
|
const plan: SpellTargetPlan = {
|
||||||
|
directTargets: new Set(directTarget ? [targetId] : []),
|
||||||
|
hotTargets: new Set(hotTarget ? [targetId] : []),
|
||||||
|
shieldTargets: new Set(shieldTarget ? [targetId] : []),
|
||||||
|
damageReductionTargets: new Set(damageReductionTarget ? [targetId] : []),
|
||||||
|
groupTargets: new Set(
|
||||||
|
spell.kind === 'group'
|
||||||
|
? groupHealTargets(party, groupTargetCount).map((member) => member.id)
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeExtraTargetBuckets.forEach((bucket) => {
|
||||||
|
addLowestHealthExtraTarget(plan, party, bucket)
|
||||||
|
})
|
||||||
|
|
||||||
|
const bucket = extraTargetMode[spell.kind] ?? 'direct'
|
||||||
|
for (let index = 0; index < extraTargets; index += 1) {
|
||||||
|
if (spell.kind === 'group') break
|
||||||
|
addLowestHealthExtraTarget(plan, party, bucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearNegativeEffects(member: PartyMember) {
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
debuff: undefined,
|
||||||
|
debuffTicks: undefined,
|
||||||
|
poisonStacks: undefined,
|
||||||
|
maxHealthPenaltyTicks: undefined,
|
||||||
|
healingReductionTicks: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FloatingHealEvent = {
|
||||||
|
memberId: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpellEffectProfile = {
|
||||||
|
/**
|
||||||
|
* Profiles keep mode-specific spell math out of screen components. PvE uses
|
||||||
|
* stacked HoT effect objects; PvP modes mostly use legacy hotTicks. Stadium
|
||||||
|
* also applies dampening through the power callbacks below.
|
||||||
|
*/
|
||||||
|
modeName: string
|
||||||
|
heal: (member: PartyMember, power: number, multiplier: number) => number
|
||||||
|
healingMultiplier: (member: PartyMember) => number
|
||||||
|
power: {
|
||||||
|
direct: (spell: Spell) => number
|
||||||
|
cleanse: (spell: Spell) => number
|
||||||
|
groupHeal: (spell: Spell) => number
|
||||||
|
groupAbsorb: (spell: Spell) => number
|
||||||
|
shield: (sourcePower: number, strength?: number) => number
|
||||||
|
}
|
||||||
|
hot: {
|
||||||
|
mode: 'effects' | 'ticks'
|
||||||
|
defaultTicks: number
|
||||||
|
groupTicks: number
|
||||||
|
radianceTicks: number
|
||||||
|
merge: 'replace' | 'max'
|
||||||
|
groupMerge?: 'replace' | 'max'
|
||||||
|
}
|
||||||
|
effects: {
|
||||||
|
renewSpell?: Spell | null
|
||||||
|
shieldSpell?: Spell | null
|
||||||
|
groupAbsorbOnly: (spell: Spell) => boolean
|
||||||
|
groupHotOnly: (spell: Spell) => boolean
|
||||||
|
groupAppliesShield: (spell: Spell) => boolean
|
||||||
|
groupAppliesHot: (spell: Spell) => boolean
|
||||||
|
shieldAppliesHot: (spell: Spell) => boolean
|
||||||
|
hotSpellForDirect: (spell: Spell) => Spell
|
||||||
|
}
|
||||||
|
ratios: {
|
||||||
|
groupShield: number
|
||||||
|
directShield: number
|
||||||
|
}
|
||||||
|
damageReductionTicks: number
|
||||||
|
floatingHeals: {
|
||||||
|
group: boolean
|
||||||
|
direct: boolean
|
||||||
|
cleanse: boolean
|
||||||
|
}
|
||||||
|
bounceHeals: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHot(
|
||||||
|
member: PartyMember,
|
||||||
|
spell: Spell,
|
||||||
|
profile: SpellEffectProfile,
|
||||||
|
ticks = profile.hot.defaultTicks,
|
||||||
|
merge = profile.hot.merge,
|
||||||
|
) {
|
||||||
|
if (profile.hot.mode === 'effects') {
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
hotTicks: 0,
|
||||||
|
hotEffects: addHotEffect(member, spell, ticks),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
hotTicks: merge === 'max'
|
||||||
|
? Math.max(member.hotTicks, ticks)
|
||||||
|
: ticks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFloatingHeal(
|
||||||
|
events: FloatingHealEvent[],
|
||||||
|
enabled: boolean,
|
||||||
|
member: PartyMember,
|
||||||
|
nextHealth: number,
|
||||||
|
) {
|
||||||
|
if (enabled && nextHealth > member.health) {
|
||||||
|
events.push({ memberId: member.id, value: nextHealth - member.health })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySpellEffectProfile({
|
||||||
|
party,
|
||||||
|
spell,
|
||||||
|
targetId,
|
||||||
|
plan,
|
||||||
|
profile,
|
||||||
|
}: {
|
||||||
|
party: PartyMember[]
|
||||||
|
spell: Spell
|
||||||
|
targetId: string
|
||||||
|
plan: SpellTargetPlan
|
||||||
|
profile: SpellEffectProfile
|
||||||
|
}) {
|
||||||
|
const floatingHeals: FloatingHealEvent[] = []
|
||||||
|
const nextParty = party.map((member) => {
|
||||||
|
if (member.health <= 0) return member
|
||||||
|
|
||||||
|
if (spell.kind === 'group') {
|
||||||
|
if (!plan.groupTargets.has(member.id)) return member
|
||||||
|
const isGroupAbsorb = profile.effects.groupAbsorbOnly(spell)
|
||||||
|
if (profile.effects.groupHotOnly(spell)) {
|
||||||
|
return applyHot(member, spell, profile, profile.hot.groupTicks, profile.hot.groupMerge)
|
||||||
|
}
|
||||||
|
|
||||||
|
const power = isGroupAbsorb
|
||||||
|
? profile.power.groupAbsorb(spell)
|
||||||
|
: profile.power.groupHeal(spell)
|
||||||
|
const nextHealth = isGroupAbsorb
|
||||||
|
? member.health
|
||||||
|
: profile.heal(member, power, profile.healingMultiplier(member))
|
||||||
|
recordFloatingHeal(floatingHeals, profile.floatingHeals.group, member, nextHealth)
|
||||||
|
|
||||||
|
let nextMember: PartyMember = {
|
||||||
|
...member,
|
||||||
|
health: nextHealth,
|
||||||
|
shield: profile.effects.groupAppliesShield(spell)
|
||||||
|
? Math.max(
|
||||||
|
member.shield,
|
||||||
|
isGroupAbsorb
|
||||||
|
? power
|
||||||
|
: profile.power.shield(
|
||||||
|
profile.effects.shieldSpell?.power ?? spell.power,
|
||||||
|
profile.ratios.groupShield,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: member.shield,
|
||||||
|
}
|
||||||
|
if (profile.effects.groupAppliesHot(spell) && profile.effects.renewSpell) {
|
||||||
|
nextMember = applyHot(nextMember, profile.effects.renewSpell, profile, profile.hot.radianceTicks, profile.hot.groupMerge)
|
||||||
|
} else if (profile.effects.groupAppliesHot(spell)) {
|
||||||
|
nextMember = applyHot(nextMember, spell, profile, profile.hot.groupTicks, profile.hot.groupMerge)
|
||||||
|
}
|
||||||
|
return nextMember
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!plan.directTargets.has(member.id)
|
||||||
|
&& !plan.hotTargets.has(member.id)
|
||||||
|
&& !plan.shieldTargets.has(member.id)
|
||||||
|
&& !plan.damageReductionTargets.has(member.id)
|
||||||
|
&& !(member.id === targetId && spell.kind === 'bounce_heal' && profile.bounceHeals)
|
||||||
|
) return member
|
||||||
|
|
||||||
|
if (spell.kind === 'shield') {
|
||||||
|
let nextMember: PartyMember = {
|
||||||
|
...member,
|
||||||
|
shield: Math.max(member.shield, profile.power.shield(spell.power)),
|
||||||
|
}
|
||||||
|
if (plan.hotTargets.has(member.id) || profile.effects.shieldAppliesHot(spell)) {
|
||||||
|
nextMember = applyHot(nextMember, profile.effects.renewSpell ?? spell, profile)
|
||||||
|
}
|
||||||
|
return nextMember
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spell.kind === 'damage_reduction') {
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
damageReductionTicks: profile.hot.merge === 'max'
|
||||||
|
? Math.max(member.damageReductionTicks ?? 0, profile.damageReductionTicks)
|
||||||
|
: profile.damageReductionTicks,
|
||||||
|
hotTicks: plan.hotTargets.has(member.id)
|
||||||
|
? (profile.hot.merge === 'max'
|
||||||
|
? Math.max(member.hotTicks, profile.hot.defaultTicks)
|
||||||
|
: profile.hot.defaultTicks)
|
||||||
|
: member.hotTicks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spell.kind === 'bounce_heal' && profile.bounceHeals) {
|
||||||
|
return { ...member, bounceHeals: addBounceHeal(member, spell) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spell.kind === 'cleanse') {
|
||||||
|
const nextHealth = profile.heal(member, profile.power.cleanse(spell), profile.healingMultiplier(member))
|
||||||
|
recordFloatingHeal(floatingHeals, profile.floatingHeals.cleanse, member, nextHealth)
|
||||||
|
let nextMember: PartyMember = {
|
||||||
|
...clearNegativeEffects(member),
|
||||||
|
health: nextHealth,
|
||||||
|
shield: plan.shieldTargets.has(member.id)
|
||||||
|
? Math.max(
|
||||||
|
member.shield,
|
||||||
|
profile.power.shield(profile.effects.shieldSpell?.power ?? spell.power),
|
||||||
|
)
|
||||||
|
: member.shield,
|
||||||
|
}
|
||||||
|
if (plan.hotTargets.has(member.id)) {
|
||||||
|
nextMember = applyHot(nextMember, profile.effects.renewSpell ?? spell, profile)
|
||||||
|
}
|
||||||
|
return nextMember
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextHealth = plan.directTargets.has(member.id)
|
||||||
|
? profile.heal(member, profile.power.direct(spell), profile.healingMultiplier(member))
|
||||||
|
: member.health
|
||||||
|
recordFloatingHeal(floatingHeals, profile.floatingHeals.direct, member, nextHealth)
|
||||||
|
let nextMember: PartyMember = {
|
||||||
|
...member,
|
||||||
|
health: nextHealth,
|
||||||
|
shield: plan.shieldTargets.has(member.id)
|
||||||
|
? Math.max(
|
||||||
|
member.shield,
|
||||||
|
profile.power.shield(
|
||||||
|
profile.effects.shieldSpell?.power ?? spell.power,
|
||||||
|
profile.ratios.directShield,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: member.shield,
|
||||||
|
}
|
||||||
|
if (plan.hotTargets.has(member.id)) {
|
||||||
|
nextMember = applyHot(nextMember, profile.effects.hotSpellForDirect(spell), profile)
|
||||||
|
}
|
||||||
|
return nextMember
|
||||||
|
})
|
||||||
|
|
||||||
|
return { party: nextParty, floatingHeals }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advanceFreeCastProgress({
|
||||||
|
enabled,
|
||||||
|
wasReady,
|
||||||
|
castsTowardFree,
|
||||||
|
}: {
|
||||||
|
enabled: boolean
|
||||||
|
wasReady: boolean
|
||||||
|
castsTowardFree: number
|
||||||
|
}) {
|
||||||
|
if (!enabled) {
|
||||||
|
return {
|
||||||
|
castsTowardFree,
|
||||||
|
freeCastReady: wasReady,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wasReady) {
|
||||||
|
return {
|
||||||
|
castsTowardFree: 0,
|
||||||
|
freeCastReady: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nextCasts = castsTowardFree + 1
|
||||||
|
return {
|
||||||
|
castsTowardFree: nextCasts >= 5 ? 0 : nextCasts,
|
||||||
|
freeCastReady: nextCasts >= 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { Spell } from '../game'
|
||||||
|
import { stackCount, type StackCounts } from './stackCounts'
|
||||||
|
|
||||||
|
export type SlotModifier<T extends string> = {
|
||||||
|
stacks: StackCounts<T>
|
||||||
|
id: (slot: string) => T
|
||||||
|
multiplier?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StackModifier<T extends string> = {
|
||||||
|
stacks: StackCounts<T>
|
||||||
|
id: T
|
||||||
|
multiplier?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotKey(spell: Spell) {
|
||||||
|
return String(spell.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotModifierStacks<T extends string>(spell: Spell, modifier?: SlotModifier<T>) {
|
||||||
|
return modifier ? stackCount(modifier.stacks, modifier.id(slotKey(spell))) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackModifierStacks<T extends string>(modifier?: StackModifier<T>) {
|
||||||
|
return modifier ? stackCount(modifier.stacks, modifier.id) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackMultiplier<T extends string>(modifier?: StackModifier<T>) {
|
||||||
|
if (!modifier) return 1
|
||||||
|
return (modifier.multiplier ?? 1.25) ** stackModifierStacks(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spellSlotMultiplier<TPositive extends string, TNegative extends string = never>(
|
||||||
|
spell: Spell,
|
||||||
|
positive?: SlotModifier<TPositive>,
|
||||||
|
negative?: SlotModifier<TNegative>,
|
||||||
|
) {
|
||||||
|
const positiveMultiplier = (positive?.multiplier ?? 0.75) ** slotModifierStacks(spell, positive)
|
||||||
|
const negativeMultiplier = (negative?.multiplier ?? 1.25) ** slotModifierStacks(spell, negative)
|
||||||
|
return positiveMultiplier * negativeMultiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spellCooldownMultiplier<TPositive extends string, TNegative extends string = never>(
|
||||||
|
spell: Spell,
|
||||||
|
cooldownDown?: SlotModifier<TPositive>,
|
||||||
|
cooldownUp?: SlotModifier<TNegative>,
|
||||||
|
) {
|
||||||
|
return spellSlotMultiplier(spell, cooldownDown, cooldownUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spellResourceCost<TPositive extends string, TNegative extends string = never>({
|
||||||
|
spell,
|
||||||
|
costDown,
|
||||||
|
costUp,
|
||||||
|
freeCastReady = false,
|
||||||
|
freeCast,
|
||||||
|
}: {
|
||||||
|
spell: Spell
|
||||||
|
costDown?: SlotModifier<TPositive>
|
||||||
|
costUp?: SlotModifier<TNegative>
|
||||||
|
freeCastReady?: boolean
|
||||||
|
freeCast?: StackModifier<TPositive>
|
||||||
|
}) {
|
||||||
|
if (freeCastReady && stackModifierStacks(freeCast) > 0) return 0
|
||||||
|
return Math.ceil(spell.cost * spellSlotMultiplier(spell, costDown, costUp))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spellExtraTargets<T extends string>(
|
||||||
|
spell: Spell,
|
||||||
|
extraTarget: SlotModifier<T>,
|
||||||
|
) {
|
||||||
|
return slotModifierStacks(spell, extraTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spellPowerMultiplier<T extends string>(modifier: StackModifier<T>) {
|
||||||
|
return stackMultiplier(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSpellStack<T extends string>(modifier: StackModifier<T>) {
|
||||||
|
return stackModifierStacks(modifier) > 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export type StackCounts<T extends string> = ReadonlyMap<T, number>
|
||||||
|
|
||||||
|
export function createStackCounts<T extends string>(items: readonly T[]): StackCounts<T> {
|
||||||
|
const counts = new Map<T, number>()
|
||||||
|
items.forEach((item) => counts.set(item, (counts.get(item) ?? 0) + 1))
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stackCount<T extends string>(counts: StackCounts<T>, id: T) {
|
||||||
|
return counts.get(id) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeStackCounts<T extends string, TChoice extends { id: T; name: string }>(
|
||||||
|
counts: StackCounts<T>,
|
||||||
|
catalog: readonly TChoice[],
|
||||||
|
emptyLabel = '',
|
||||||
|
) {
|
||||||
|
const summary = Array.from(counts.entries())
|
||||||
|
.map(([id, count]) => {
|
||||||
|
const label = catalog.find((choice) => choice.id === id)?.name ?? id
|
||||||
|
return count > 1 ? `${label} x${count}` : label
|
||||||
|
})
|
||||||
|
.join(', ')
|
||||||
|
return summary || emptyLabel
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
export type StadiumRoundOutcome = 'win' | 'loss' | 'tie'
|
||||||
|
export type StadiumWins = {
|
||||||
|
player: number
|
||||||
|
opponent: number
|
||||||
|
}
|
||||||
|
export type StadiumRoundStatus = 'playing' | 'shop' | 'won' | 'lost'
|
||||||
|
export type StadiumExperienceMode =
|
||||||
|
| 'pvp-stadium-round-win-quarter-level'
|
||||||
|
| 'pvp-stadium-round-loss-tenth-level'
|
||||||
|
| 'pvp-stadium-match-half-level'
|
||||||
|
|
||||||
|
export const DEFAULT_STADIUM_WIN_ROUNDS = 3
|
||||||
|
|
||||||
|
export function stadiumShopPointsForOutcome(outcome: StadiumRoundOutcome, side: 'player' | 'opponent') {
|
||||||
|
if (side === 'player') return outcome === 'loss' ? 4 : 3
|
||||||
|
return outcome === 'win' ? 4 : 3
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStadiumRound({
|
||||||
|
outcome,
|
||||||
|
roundIndex,
|
||||||
|
wins,
|
||||||
|
winRounds = DEFAULT_STADIUM_WIN_ROUNDS,
|
||||||
|
}: {
|
||||||
|
outcome: StadiumRoundOutcome
|
||||||
|
roundIndex: number
|
||||||
|
wins: StadiumWins
|
||||||
|
winRounds?: number
|
||||||
|
}) {
|
||||||
|
const roundExperienceMode: StadiumExperienceMode = outcome === 'loss'
|
||||||
|
? 'pvp-stadium-round-loss-tenth-level'
|
||||||
|
: 'pvp-stadium-round-win-quarter-level'
|
||||||
|
const logTone = outcome === 'loss' ? 'danger' as const : 'loot' as const
|
||||||
|
const nextWins = {
|
||||||
|
player: wins.player + (outcome === 'win' ? 1 : 0),
|
||||||
|
opponent: wins.opponent + (outcome === 'loss' ? 1 : 0),
|
||||||
|
}
|
||||||
|
const status: StadiumRoundStatus = nextWins.player >= winRounds
|
||||||
|
? 'won'
|
||||||
|
: nextWins.opponent >= winRounds
|
||||||
|
? 'lost'
|
||||||
|
: 'shop'
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextWins,
|
||||||
|
status,
|
||||||
|
playerRoundStatus: status,
|
||||||
|
roundExperience: {
|
||||||
|
key: `round-${roundIndex}-${outcome}`,
|
||||||
|
mode: roundExperienceMode,
|
||||||
|
},
|
||||||
|
matchExperience: status === 'won'
|
||||||
|
? { key: 'match-win', mode: 'pvp-stadium-match-half-level' as StadiumExperienceMode }
|
||||||
|
: null,
|
||||||
|
log: {
|
||||||
|
text: outcome === 'win'
|
||||||
|
? `Round ${roundIndex} won.`
|
||||||
|
: outcome === 'loss'
|
||||||
|
? `Round ${roundIndex} lost.`
|
||||||
|
: `Round ${roundIndex} tied.`,
|
||||||
|
tone: logTone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chooseStadiumCpuPurchases<TId extends string, TBuff extends { id: TId; cost: number }>(
|
||||||
|
catalog: readonly TBuff[],
|
||||||
|
points: number,
|
||||||
|
random = Math.random,
|
||||||
|
) {
|
||||||
|
let remaining = points
|
||||||
|
const purchases: TId[] = []
|
||||||
|
while (remaining > 0) {
|
||||||
|
const affordable = catalog.filter((buff) => buff.cost <= remaining)
|
||||||
|
if (affordable.length === 0) break
|
||||||
|
const selected = affordable[Math.floor(random() * affordable.length)]
|
||||||
|
purchases.push(selected.id)
|
||||||
|
remaining -= selected.cost
|
||||||
|
}
|
||||||
|
return purchases
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { PartyMember } from '../game'
|
||||||
|
import type { PvpMatchSnapshot, PvpMatchSide } from '../pvpRoguelike'
|
||||||
|
import { resetParty } from './rules'
|
||||||
|
import type { StadiumRoundOutcome, StadiumWins } from './stadiumLifecycle'
|
||||||
|
|
||||||
|
export type StadiumSetupSide<TBuff extends string> = {
|
||||||
|
party: PartyMember[]
|
||||||
|
resource: number
|
||||||
|
cooldowns: Record<string, number>
|
||||||
|
buffs: TBuff[]
|
||||||
|
castsTowardFree: number
|
||||||
|
freeCastReady: boolean
|
||||||
|
survivalSeconds: number
|
||||||
|
dampeningPercent: number
|
||||||
|
roundIndex: number
|
||||||
|
roundWins: number
|
||||||
|
roundStatus: 'playing' | 'shop' | 'won' | 'lost'
|
||||||
|
lastRoundOutcome?: StadiumRoundOutcome
|
||||||
|
shopReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StadiumLiveMatchSetup = {
|
||||||
|
id: string
|
||||||
|
side: PvpMatchSide
|
||||||
|
opponentSide: PvpMatchSide
|
||||||
|
opponentName: string
|
||||||
|
opponentClassName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStadiumStarterSide<TBuff extends string>({
|
||||||
|
partyTemplate,
|
||||||
|
maxResource,
|
||||||
|
roundIndex,
|
||||||
|
buffs = [],
|
||||||
|
roundWins = 0,
|
||||||
|
}: {
|
||||||
|
partyTemplate: readonly PartyMember[]
|
||||||
|
maxResource: number
|
||||||
|
roundIndex: number
|
||||||
|
buffs?: TBuff[]
|
||||||
|
roundWins?: number
|
||||||
|
}): StadiumSetupSide<TBuff> {
|
||||||
|
return {
|
||||||
|
party: resetParty([...partyTemplate]),
|
||||||
|
resource: maxResource,
|
||||||
|
cooldowns: {},
|
||||||
|
buffs,
|
||||||
|
castsTowardFree: 0,
|
||||||
|
freeCastReady: false,
|
||||||
|
survivalSeconds: 0,
|
||||||
|
dampeningPercent: 0,
|
||||||
|
roundIndex,
|
||||||
|
roundWins,
|
||||||
|
roundStatus: 'playing',
|
||||||
|
shopReady: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStadiumMatchStart<TBuff extends string>({
|
||||||
|
partyTemplate,
|
||||||
|
opponentPartyTemplate,
|
||||||
|
maxResource,
|
||||||
|
}: {
|
||||||
|
partyTemplate: readonly PartyMember[]
|
||||||
|
opponentPartyTemplate: readonly PartyMember[]
|
||||||
|
maxResource: number
|
||||||
|
}) {
|
||||||
|
const roundIndex = 1
|
||||||
|
return {
|
||||||
|
playerSide: createStadiumStarterSide<TBuff>({ partyTemplate, maxResource, roundIndex }),
|
||||||
|
opponentSide: createStadiumStarterSide<TBuff>({ partyTemplate: opponentPartyTemplate, maxResource, roundIndex }),
|
||||||
|
defaults: {
|
||||||
|
roundIndex,
|
||||||
|
roundWins: { player: 0, opponent: 0 } satisfies StadiumWins,
|
||||||
|
elapsedTicks: 0,
|
||||||
|
shopPoints: 0,
|
||||||
|
shopReady: false,
|
||||||
|
paused: false,
|
||||||
|
rewardError: '',
|
||||||
|
showEndLog: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStadiumLiveMatchStart<TBuff extends string>({
|
||||||
|
match,
|
||||||
|
side,
|
||||||
|
partyTemplate,
|
||||||
|
opponentPartyTemplate,
|
||||||
|
maxResource,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
match: PvpMatchSnapshot<StadiumSetupSide<TBuff>>
|
||||||
|
side: PvpMatchSide
|
||||||
|
partyTemplate: readonly PartyMember[]
|
||||||
|
opponentPartyTemplate: readonly PartyMember[]
|
||||||
|
maxResource: number
|
||||||
|
message?: string
|
||||||
|
}) {
|
||||||
|
const opponentSideId: PvpMatchSide = side === 'a' ? 'b' : 'a'
|
||||||
|
const opponent = match.players[opponentSideId]
|
||||||
|
const opponentTemplate = opponentPartyTemplate.map((member) => ({
|
||||||
|
...member,
|
||||||
|
name: member.id === 'mira' ? opponent.characterName : member.name,
|
||||||
|
}))
|
||||||
|
const setup = createStadiumMatchStart<TBuff>({
|
||||||
|
partyTemplate,
|
||||||
|
opponentPartyTemplate: opponentTemplate,
|
||||||
|
maxResource,
|
||||||
|
})
|
||||||
|
const liveMatch: StadiumLiveMatchSetup = {
|
||||||
|
id: match.id,
|
||||||
|
side,
|
||||||
|
opponentSide: opponentSideId,
|
||||||
|
opponentName: opponent.characterName,
|
||||||
|
opponentClassName: opponent.className,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...setup,
|
||||||
|
liveMatch,
|
||||||
|
logText: message ?? `${opponent.characterName} found. Stadium begins.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { PartyMember } from '../game'
|
||||||
|
|
||||||
|
export type NavigateAction = 'navigateLeft' | 'navigateRight' | 'navigateUp' | 'navigateDown'
|
||||||
|
|
||||||
|
type TargetOptions = {
|
||||||
|
livingOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function canTarget(member: PartyMember, options: TargetOptions) {
|
||||||
|
return !options.livingOnly || member.health > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectRelativePartyTarget(
|
||||||
|
party: PartyMember[],
|
||||||
|
selectedId: string,
|
||||||
|
direction: -1 | 1,
|
||||||
|
options: TargetOptions = {},
|
||||||
|
) {
|
||||||
|
const candidates = party.filter((member) => canTarget(member, options))
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
const currentIndex = candidates.findIndex((member) => member.id === selectedId)
|
||||||
|
const nextIndex = currentIndex < 0
|
||||||
|
? 0
|
||||||
|
: (currentIndex + direction + candidates.length) % candidates.length
|
||||||
|
return candidates[nextIndex]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectDirectionalPartyTarget(
|
||||||
|
party: PartyMember[],
|
||||||
|
selectedId: string,
|
||||||
|
action: NavigateAction,
|
||||||
|
columns: number,
|
||||||
|
options: TargetOptions = {},
|
||||||
|
) {
|
||||||
|
const currentIndex = party.findIndex((member) => member.id === selectedId)
|
||||||
|
if (currentIndex < 0) {
|
||||||
|
return party.find((member) => canTarget(member, options))?.id ?? null
|
||||||
|
}
|
||||||
|
const currentRow = Math.floor(currentIndex / columns)
|
||||||
|
const currentColumn = currentIndex % columns
|
||||||
|
const horizontal = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
const candidate = party
|
||||||
|
.map((member, index) => ({
|
||||||
|
member,
|
||||||
|
index,
|
||||||
|
row: Math.floor(index / columns),
|
||||||
|
column: index % columns,
|
||||||
|
}))
|
||||||
|
.filter(({ member, index, row, column }) => {
|
||||||
|
if (!canTarget(member, options) || index === currentIndex) return false
|
||||||
|
if (action === 'navigateLeft') return row === currentRow && column < currentColumn
|
||||||
|
if (action === 'navigateRight') return row === currentRow && column > currentColumn
|
||||||
|
if (action === 'navigateUp') return row < currentRow
|
||||||
|
return row > currentRow
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftPrimary = horizontal
|
||||||
|
? Math.abs(left.column - currentColumn)
|
||||||
|
: Math.abs(left.row - currentRow)
|
||||||
|
const rightPrimary = horizontal
|
||||||
|
? Math.abs(right.column - currentColumn)
|
||||||
|
: Math.abs(right.row - currentRow)
|
||||||
|
const leftSecondary = horizontal ? 0 : Math.abs(left.column - currentColumn)
|
||||||
|
const rightSecondary = horizontal ? 0 : Math.abs(right.column - currentColumn)
|
||||||
|
return leftPrimary - rightPrimary || leftSecondary - rightSecondary
|
||||||
|
})[0]
|
||||||
|
return candidate?.member.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectDirectPartyTarget(
|
||||||
|
party: PartyMember[],
|
||||||
|
slot: number,
|
||||||
|
options: TargetOptions & {
|
||||||
|
targetGroup?: number
|
||||||
|
groupSize?: number
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const groupSize = options.groupSize ?? 6
|
||||||
|
const index = slot + (options.targetGroup ?? 0) * groupSize
|
||||||
|
const member = party[index]
|
||||||
|
return member && canTarget(member, options) ? member.id : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextTargetGroupSelection(
|
||||||
|
party: PartyMember[],
|
||||||
|
selectedId: string,
|
||||||
|
currentGroup: number,
|
||||||
|
groupSize = 6,
|
||||||
|
) {
|
||||||
|
const groupCount = Math.max(1, Math.ceil(party.length / groupSize))
|
||||||
|
const nextGroup = (currentGroup + 1) % groupCount
|
||||||
|
const selectedIndex = party.findIndex((member) => member.id === selectedId)
|
||||||
|
const slot = selectedIndex < 0 ? 0 : selectedIndex % groupSize
|
||||||
|
return {
|
||||||
|
group: nextGroup,
|
||||||
|
selectedId: party[slot + nextGroup * groupSize]?.id ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
|
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
|
||||||
|
import {
|
||||||
|
IWT2_BOSS_METADATA,
|
||||||
|
type Iwt2BalanceOverrides,
|
||||||
|
type Iwt2BossId,
|
||||||
|
type Iwt2BossMetadata,
|
||||||
|
} from '../modes/iwt2/content/bosses'
|
||||||
|
|
||||||
type AdminItem = {
|
type AdminItem = {
|
||||||
id: number
|
id: number
|
||||||
@@ -130,7 +136,38 @@ type AdminData = {
|
|||||||
classes: AdminClass[]
|
classes: AdminClass[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminTab = 'items' | 'dungeons' | 'encounters' | 'loot' | 'crafting' | 'upgrades' | 'classes'
|
type Iwt2AdminAsset = {
|
||||||
|
filename: string
|
||||||
|
size: number
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Iwt2AdminData = {
|
||||||
|
balanceOverrides: Iwt2BalanceOverrides
|
||||||
|
bossAssets: Iwt2AdminAsset[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Iwt2AdminAttack = {
|
||||||
|
name: string
|
||||||
|
frequency: string
|
||||||
|
damage: string
|
||||||
|
details: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Iwt2AdminEntityRow = {
|
||||||
|
id: string
|
||||||
|
bossId: Iwt2BossId
|
||||||
|
kind: 'boss' | 'mob'
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
maxHealth: number
|
||||||
|
attacks: Iwt2AdminAttack[]
|
||||||
|
assets: Iwt2AdminAsset[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminTab = 'items' | 'dungeons' | 'encounters' | 'loot' | 'crafting' | 'upgrades' | 'classes' | 'iwt2'
|
||||||
type SavingState = Record<string, boolean>
|
type SavingState = Record<string, boolean>
|
||||||
type SetData = Dispatch<SetStateAction<AdminData | null>>
|
type SetData = Dispatch<SetStateAction<AdminData | null>>
|
||||||
type SetSaving = Dispatch<SetStateAction<SavingState>>
|
type SetSaving = Dispatch<SetStateAction<SavingState>>
|
||||||
@@ -144,6 +181,7 @@ const tabs: { id: AdminTab; label: string }[] = [
|
|||||||
{ id: 'crafting', label: 'Crafting' },
|
{ id: 'crafting', label: 'Crafting' },
|
||||||
{ id: 'upgrades', label: 'Upgrades' },
|
{ id: 'upgrades', label: 'Upgrades' },
|
||||||
{ id: 'classes', label: 'Classes' },
|
{ id: 'classes', label: 'Classes' },
|
||||||
|
{ id: 'iwt2', label: 'IWT2' },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -189,6 +227,7 @@ export function AdminScreen({ onBack }: { onBack: () => void }) {
|
|||||||
{tab === 'crafting' && <CraftingTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
{tab === 'crafting' && <CraftingTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
||||||
{tab === 'upgrades' && <UpgradesTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
{tab === 'upgrades' && <UpgradesTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
||||||
{tab === 'classes' && <ClassesTab data={data} />}
|
{tab === 'classes' && <ClassesTab data={data} />}
|
||||||
|
{tab === 'iwt2' && <Iwt2AdminTab />}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1155,6 +1194,196 @@ function ClassesTab({ data }: { data: AdminData }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Iwt2AdminTab() {
|
||||||
|
const [metadata, setMetadata] = useState<Record<Iwt2BossId, Iwt2BossMetadata>>(IWT2_BOSS_METADATA)
|
||||||
|
const [adminData, setAdminData] = useState<Iwt2AdminData | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [selectedEntityId, setSelectedEntityId] = useState<string>('bulldrome')
|
||||||
|
const [draftHp, setDraftHp] = useState<Record<string, string>>({})
|
||||||
|
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchJson<Iwt2AdminData>(`${API}/iwt2/data`)
|
||||||
|
.then((result) => {
|
||||||
|
setAdminData(result)
|
||||||
|
setMetadata(applyIwt2AdminOverrides(IWT2_BOSS_METADATA, result.balanceOverrides))
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load IWT2 admin data')
|
||||||
|
setAdminData({ balanceOverrides: { bosses: {} }, bossAssets: [] })
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const rows = createIwt2AdminRows(metadata, adminData?.bossAssets ?? [])
|
||||||
|
const selectedRow = rows.find((row) => row.id === selectedEntityId) ?? rows[0]
|
||||||
|
|
||||||
|
async function saveHp(row: Iwt2AdminEntityRow) {
|
||||||
|
const value = Number(draftHp[row.id] ?? row.maxHealth)
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
alert('HP must be greater than zero.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextHealth = Math.round(value)
|
||||||
|
setSaving((prev) => ({ ...prev, [row.id]: true }))
|
||||||
|
try {
|
||||||
|
await fetchJson(`${API}/iwt2/entities/${encodeURIComponent(row.id)}/hp`, jsonRequest('PUT', { maxHealth: nextHealth }))
|
||||||
|
setMetadata((current) => {
|
||||||
|
const boss = current[row.bossId]
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[row.bossId]: row.kind === 'boss'
|
||||||
|
? { ...boss, maxHealth: nextHealth }
|
||||||
|
: { ...boss, birdHealth: nextHealth },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setDraftHp((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[row.id]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Save failed')
|
||||||
|
} finally {
|
||||||
|
setSaving((prev) => ({ ...prev, [row.id]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-panel iwt2-admin-panel">
|
||||||
|
{error && <p className="error-message">{error}</p>}
|
||||||
|
<div className="iwt2-admin-summary">
|
||||||
|
<span>{rows.length} mobs / bosses</span>
|
||||||
|
<span>{adminData?.bossAssets.length ?? 0} uploaded boss assets</span>
|
||||||
|
<span>HP edits write to IWT2 balance overrides</span>
|
||||||
|
</div>
|
||||||
|
<div className="iwt2-admin-layout">
|
||||||
|
<aside className="iwt2-admin-entity-list" aria-label="IWT2 mobs and bosses">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<button
|
||||||
|
className={selectedRow?.id === row.id ? 'active' : ''}
|
||||||
|
key={row.id}
|
||||||
|
onClick={() => setSelectedEntityId(row.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="iwt2-admin-token" style={{ '--iwt2-entity-color': row.color } as CSSProperties}>{row.icon}</span>
|
||||||
|
<span>
|
||||||
|
<strong>{row.name}</strong>
|
||||||
|
<small>{row.kind} · {row.maxHealth} hp · {row.attacks.length} attacks</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
{selectedRow && (
|
||||||
|
<Iwt2AdminDetail
|
||||||
|
draftHp={draftHp}
|
||||||
|
row={selectedRow}
|
||||||
|
saveHp={saveHp}
|
||||||
|
saving={saving}
|
||||||
|
setDraftHp={setDraftHp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Iwt2AdminDetail({
|
||||||
|
draftHp,
|
||||||
|
row,
|
||||||
|
saveHp,
|
||||||
|
saving,
|
||||||
|
setDraftHp,
|
||||||
|
}: {
|
||||||
|
draftHp: Record<string, string>
|
||||||
|
row: Iwt2AdminEntityRow
|
||||||
|
saveHp: (row: Iwt2AdminEntityRow) => void
|
||||||
|
saving: Record<string, boolean>
|
||||||
|
setDraftHp: Dispatch<SetStateAction<Record<string, string>>>
|
||||||
|
}) {
|
||||||
|
const value = draftHp[row.id] ?? String(row.maxHealth)
|
||||||
|
const changed = Number(value) !== row.maxHealth
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="iwt2-admin-detail">
|
||||||
|
<header className="iwt2-admin-detail-hero">
|
||||||
|
<span className="iwt2-admin-token" style={{ '--iwt2-entity-color': row.color } as CSSProperties}>{row.icon}</span>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{row.kind}</p>
|
||||||
|
<h2>{row.name}</h2>
|
||||||
|
<small>{row.slug} · source boss: {row.bossId}</small>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<label className="iwt2-admin-hp">
|
||||||
|
HP
|
||||||
|
<input
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setDraftHp((prev) => ({ ...prev, [row.id]: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={!changed || saving[row.id]}
|
||||||
|
onClick={() => saveHp(row)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{saving[row.id] ? 'Saving...' : 'Save HP'}
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<section className="iwt2-admin-section">
|
||||||
|
<div className="iwt2-admin-section-heading">
|
||||||
|
<h3>Assets</h3>
|
||||||
|
<span>{row.assets.length} uploaded</span>
|
||||||
|
</div>
|
||||||
|
<div className="iwt2-admin-assets">
|
||||||
|
<div className="iwt2-admin-swatch" style={{ '--iwt2-entity-color': row.color } as CSSProperties}>
|
||||||
|
<span>{row.icon}</span>
|
||||||
|
<strong>Runtime Token</strong>
|
||||||
|
<small>Icon and color used by current arena renderer.</small>
|
||||||
|
</div>
|
||||||
|
{row.assets.length > 0 ? row.assets.map((asset) => {
|
||||||
|
const label = iwt2AssetLabel(asset.filename)
|
||||||
|
return (
|
||||||
|
<a href={asset.url} key={asset.filename} target="_blank" rel="noreferrer">
|
||||||
|
<img src={asset.url} alt={`${row.name} ${label}`} />
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<small>{asset.filename}</small>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}) : (
|
||||||
|
<p className="admin-item-desc">No uploaded raster asset matched. Runtime uses shape, icon, and color.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="iwt2-admin-section">
|
||||||
|
<div className="iwt2-admin-section-heading">
|
||||||
|
<h3>Attacks</h3>
|
||||||
|
<span>{row.attacks.length} mechanics</span>
|
||||||
|
</div>
|
||||||
|
<div className="iwt2-admin-attacks">
|
||||||
|
<div className="iwt2-admin-attack-head">
|
||||||
|
<span>Attack</span>
|
||||||
|
<span>Frequency</span>
|
||||||
|
<span>Damage</span>
|
||||||
|
</div>
|
||||||
|
{row.attacks.map((attack) => (
|
||||||
|
<div className="iwt2-admin-attack-row" key={`${row.id}-${attack.name}`}>
|
||||||
|
<strong>{attack.name}</strong>
|
||||||
|
<span>{attack.frequency}</span>
|
||||||
|
<span>{attack.damage}</span>
|
||||||
|
<small>{attack.details}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
|
function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
|
||||||
return {
|
return {
|
||||||
method,
|
method,
|
||||||
@@ -1163,6 +1392,136 @@ function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyIwt2AdminOverrides(
|
||||||
|
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
|
||||||
|
overrides: Iwt2BalanceOverrides,
|
||||||
|
): Record<Iwt2BossId, Iwt2BossMetadata> {
|
||||||
|
return {
|
||||||
|
bulldrome: {
|
||||||
|
...metadata.bulldrome,
|
||||||
|
...overrides.bosses?.bulldrome,
|
||||||
|
},
|
||||||
|
'yian-kut-ku': {
|
||||||
|
...metadata['yian-kut-ku'],
|
||||||
|
...overrides.bosses?.['yian-kut-ku'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIwt2AdminRows(
|
||||||
|
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
|
||||||
|
bossAssets: Iwt2AdminAsset[],
|
||||||
|
): Iwt2AdminEntityRow[] {
|
||||||
|
const bosses = Object.values(metadata).map((boss): Iwt2AdminEntityRow => ({
|
||||||
|
assets: assetsForIwt2Boss(boss.id, bossAssets),
|
||||||
|
attacks: bossAttacks(boss),
|
||||||
|
bossId: boss.id,
|
||||||
|
color: boss.color,
|
||||||
|
icon: boss.icon,
|
||||||
|
id: boss.id,
|
||||||
|
kind: 'boss',
|
||||||
|
maxHealth: boss.maxHealth,
|
||||||
|
name: boss.name,
|
||||||
|
slug: boss.id,
|
||||||
|
}))
|
||||||
|
const yian = metadata['yian-kut-ku']
|
||||||
|
if (!yian.birdHealth) return bosses
|
||||||
|
return [
|
||||||
|
...bosses,
|
||||||
|
{
|
||||||
|
assets: [],
|
||||||
|
attacks: yianBirdAttacks(yian),
|
||||||
|
bossId: yian.id,
|
||||||
|
color: '#f0b84f',
|
||||||
|
icon: 'v',
|
||||||
|
id: `${yian.id}:yian-bird`,
|
||||||
|
kind: 'mob',
|
||||||
|
maxHealth: yian.birdHealth,
|
||||||
|
name: 'Yian Kut Ku Hatchling',
|
||||||
|
slug: 'yian-bird',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function bossAttacks(boss: Iwt2BossMetadata): Iwt2AdminAttack[] {
|
||||||
|
const attacks: Iwt2AdminAttack[] = [{
|
||||||
|
damage: `${boss.meleeDamage}`,
|
||||||
|
details: `Range ${boss.meleeRange}px`,
|
||||||
|
frequency: `${formatSeconds(boss.meleeCooldown)} cooldown`,
|
||||||
|
name: 'Melee',
|
||||||
|
}]
|
||||||
|
if (boss.chargeCooldown > 0) {
|
||||||
|
attacks.push({
|
||||||
|
damage: `${boss.chargeDamage}`,
|
||||||
|
details: `${formatSeconds(boss.chargeWindup)} windup, ${formatSeconds(boss.chargeStunSeconds)} stun / knockdown`,
|
||||||
|
frequency: `${formatSeconds(boss.chargeCooldown)} cooldown`,
|
||||||
|
name: 'Charge',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (boss.slamDamage > 0) {
|
||||||
|
attacks.push({
|
||||||
|
damage: `${boss.slamDamage}`,
|
||||||
|
details: `${boss.slamRadius}px radius, ${formatSeconds(boss.slamStunSeconds)} stun / knockdown`,
|
||||||
|
frequency: 'Every 3rd charge',
|
||||||
|
name: 'Ground Slam',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (boss.fireballCooldown && boss.fireballDamage) {
|
||||||
|
attacks.push({
|
||||||
|
damage: `${boss.fireballDamage} hit + ${boss.firePuddleDamage ?? 0}/tick puddle`,
|
||||||
|
details: `${boss.firePuddleRadius ?? 0}px puddle for ${formatSeconds(boss.firePuddleSeconds ?? 0)}`,
|
||||||
|
frequency: `${formatSeconds(boss.fireballCooldown)} cooldown`,
|
||||||
|
name: 'Fireball',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (boss.birdWaveThresholds?.length) {
|
||||||
|
attacks.push({
|
||||||
|
damage: `${boss.birdContactDamage ?? 0} contact`,
|
||||||
|
details: `${boss.birdWaveThresholds.map((threshold) => `${Math.round(threshold * 100)}%`).join(', ')} HP thresholds`,
|
||||||
|
frequency: 'Health threshold',
|
||||||
|
name: 'Bird Wave',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return attacks
|
||||||
|
}
|
||||||
|
|
||||||
|
function yianBirdAttacks(boss: Iwt2BossMetadata): Iwt2AdminAttack[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
damage: `${boss.birdContactDamage ?? 0}`,
|
||||||
|
details: `Contact radius ${boss.birdRadius ?? 0}px`,
|
||||||
|
frequency: '1.15s cooldown',
|
||||||
|
name: 'Peck',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
damage: '10',
|
||||||
|
details: `${formatSeconds(boss.birdFlightWindup ?? 0)} windup, ${formatSeconds(boss.birdStunSeconds ?? 0)} stun`,
|
||||||
|
frequency: `${formatSeconds(boss.birdFlightCooldown ?? 0)} cooldown`,
|
||||||
|
name: 'Flight Lane',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetsForIwt2Boss(bossId: Iwt2BossId, bossAssets: Iwt2AdminAsset[]): Iwt2AdminAsset[] {
|
||||||
|
const terms = bossId.split('-').filter((part) => part.length > 2)
|
||||||
|
return bossAssets.filter((asset) => {
|
||||||
|
const filename = asset.filename.toLowerCase()
|
||||||
|
return filename.includes(bossId) || terms.some((term) => filename.includes(term))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function iwt2AssetLabel(filename: string) {
|
||||||
|
const normalized = filename.toLowerCase()
|
||||||
|
if (normalized.includes('approach')) return 'Approach Illustration'
|
||||||
|
if (normalized.includes('guardians')) return 'Party Encounter Art'
|
||||||
|
if (normalized.includes('boss')) return 'Boss Portrait'
|
||||||
|
return 'Primary Boss Art'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(value: number) {
|
||||||
|
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2).replace(/0$/, '')}s`
|
||||||
|
}
|
||||||
|
|
||||||
function fileToDataUrl(file: File) {
|
function fileToDataUrl(file: File) {
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CSSProperties } from 'react'
|
import { memo, type CSSProperties } from 'react'
|
||||||
import {
|
import {
|
||||||
bindingLabel,
|
bindingLabel,
|
||||||
compactBindingLabel,
|
compactBindingLabel,
|
||||||
@@ -31,7 +31,7 @@ function faceButtonFor(binding: string, iconStyle: ControllerIconStyle) {
|
|||||||
return FACE_BUTTONS[iconStyle][Number(binding.slice(6))] ?? null
|
return FACE_BUTTONS[iconStyle][Number(binding.slice(6))] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function FaceIcon({
|
const FaceIcon = memo(function FaceIcon({
|
||||||
color,
|
color,
|
||||||
iconStyle,
|
iconStyle,
|
||||||
label,
|
label,
|
||||||
@@ -52,9 +52,9 @@ function FaceIcon({
|
|||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
export function ControllerBindingLabel({
|
export const ControllerBindingLabel = memo(function ControllerBindingLabel({
|
||||||
binding,
|
binding,
|
||||||
compact = false,
|
compact = false,
|
||||||
iconStyle,
|
iconStyle,
|
||||||
@@ -78,9 +78,9 @@ export function ControllerBindingLabel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <>{compact ? compactBindingLabel(binding, iconStyle) : title}</>
|
return <>{compact ? compactBindingLabel(binding, iconStyle) : title}</>
|
||||||
}
|
})
|
||||||
|
|
||||||
export function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) {
|
export const ControllerStylePreview = memo(function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) {
|
||||||
return (
|
return (
|
||||||
<span className="controller-style-preview" aria-hidden="true">
|
<span className="controller-style-preview" aria-hidden="true">
|
||||||
{[0, 1, 2, 3].map((button) => {
|
{[0, 1, 2, 3].map((button) => {
|
||||||
@@ -99,4 +99,4 @@ export function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIco
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||||
import {
|
import {
|
||||||
saveProfile,
|
saveProfile,
|
||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
type GameClass,
|
type GameClass,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
|
import { useGameAction, type InputAction } from '../input'
|
||||||
import { EquipmentScreen } from './EquipmentScreen'
|
import { EquipmentScreen } from './EquipmentScreen'
|
||||||
import { TalentScreen } from './TalentScreen'
|
import { TalentScreen } from './TalentScreen'
|
||||||
|
|
||||||
@@ -14,20 +15,121 @@ type Props = {
|
|||||||
onSaved: (profile: CharacterProfile) => void
|
onSaved: (profile: CharacterProfile) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CustomizeTab = 'equipment' | 'crafting' | 'talents' | 'class'
|
||||||
|
|
||||||
|
type CustomizeNavEntry =
|
||||||
|
| { kind: 'back'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'tab'; key: string; row: number; column: number; tab: CustomizeTab }
|
||||||
|
| { kind: 'class'; key: string; row: number; column: number; classId: number }
|
||||||
|
| { kind: 'slot'; key: string; row: number; column: number; slotIndex: number }
|
||||||
|
| { kind: 'clear'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'abilityPagePrev'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'abilityPageNext'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'ability'; key: string; row: number; column: number; abilityId: number }
|
||||||
|
| { kind: 'save'; key: string; row: number; column: number }
|
||||||
|
|
||||||
|
const CUSTOMIZE_TABS: Array<{ key: CustomizeTab; label: string }> = [
|
||||||
|
{ key: 'equipment', label: 'Equipment' },
|
||||||
|
{ key: 'crafting', label: 'Crafting' },
|
||||||
|
{ key: 'talents', label: 'Talents' },
|
||||||
|
{ key: 'class', label: 'Class' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CLASS_PICKER_COLUMN = 0
|
||||||
|
const CLASS_CONTENT_COLUMN = 2
|
||||||
|
const ABILITY_LIBRARY_COLUMNS = 5
|
||||||
|
const ABILITY_LIBRARY_PAGE_SIZE = 10
|
||||||
|
|
||||||
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||||
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
|
const [activeTab, setActiveTab] = useState<CustomizeTab>('class')
|
||||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [classId, setClassId] = useState(profile.character.classId)
|
const [classId, setClassId] = useState(profile.character.classId)
|
||||||
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
||||||
const [selectedSlot, setSelectedSlot] = useState(0)
|
const [selectedSlot, setSelectedSlot] = useState(0)
|
||||||
|
const [selectedNavKey, setSelectedNavKey] = useState('tab:class')
|
||||||
|
const [abilityPage, setAbilityPage] = useState(0)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const scrollRef = useRef<number>(0)
|
const scrollRef = useRef<number>(0)
|
||||||
|
const navRefs = useRef<Record<string, HTMLElement | null>>({})
|
||||||
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
|
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
|
||||||
const abilityMap = useMemo(
|
const abilityMap = useMemo(
|
||||||
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
|
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
|
||||||
[gameClass],
|
[gameClass],
|
||||||
)
|
)
|
||||||
|
const abilityPageCount = Math.max(1, Math.ceil(gameClass.spells.length / ABILITY_LIBRARY_PAGE_SIZE))
|
||||||
|
const currentAbilityPage = Math.min(abilityPage, abilityPageCount - 1)
|
||||||
|
const visibleAbilities = useMemo(
|
||||||
|
() => gameClass.spells.slice(
|
||||||
|
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE,
|
||||||
|
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE + ABILITY_LIBRARY_PAGE_SIZE,
|
||||||
|
),
|
||||||
|
[currentAbilityPage, gameClass.spells],
|
||||||
|
)
|
||||||
|
const classNavActive = activeTab === 'class'
|
||||||
|
const navEntries = useMemo<CustomizeNavEntry[]>(() => {
|
||||||
|
const entries: CustomizeNavEntry[] = [
|
||||||
|
{ kind: 'back', key: 'back', row: 0, column: 0 },
|
||||||
|
...CUSTOMIZE_TABS.map((tab, index) => ({
|
||||||
|
kind: 'tab' as const,
|
||||||
|
key: `tab:${tab.key}`,
|
||||||
|
row: 0,
|
||||||
|
column: index + 1,
|
||||||
|
tab: tab.key,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
if (activeTab !== 'class') return entries
|
||||||
|
|
||||||
|
entries.push(
|
||||||
|
...profile.classes.map((candidate, index) => ({
|
||||||
|
kind: 'class' as const,
|
||||||
|
key: `class:${candidate.id}`,
|
||||||
|
row: index + 1,
|
||||||
|
column: CLASS_PICKER_COLUMN,
|
||||||
|
classId: candidate.id,
|
||||||
|
})),
|
||||||
|
...slots.map((_, index) => ({
|
||||||
|
kind: 'slot' as const,
|
||||||
|
key: `slot:${index}`,
|
||||||
|
row: 1,
|
||||||
|
column: CLASS_CONTENT_COLUMN + index,
|
||||||
|
slotIndex: index,
|
||||||
|
})),
|
||||||
|
{ kind: 'clear', key: 'clear-slot', row: 2, column: CLASS_CONTENT_COLUMN + 4 },
|
||||||
|
...(abilityPageCount > 1 && currentAbilityPage > 0
|
||||||
|
? [{ kind: 'abilityPagePrev' as const, key: 'ability-page-prev', row: 2, column: CLASS_CONTENT_COLUMN + 2 }]
|
||||||
|
: []),
|
||||||
|
...(abilityPageCount > 1 && currentAbilityPage < abilityPageCount - 1
|
||||||
|
? [{ kind: 'abilityPageNext' as const, key: 'ability-page-next', row: 2, column: CLASS_CONTENT_COLUMN + 3 }]
|
||||||
|
: []),
|
||||||
|
...visibleAbilities
|
||||||
|
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
||||||
|
.map((ability, index) => ({
|
||||||
|
kind: 'ability' as const,
|
||||||
|
key: `ability:${ability.id}`,
|
||||||
|
row: Math.floor(index / ABILITY_LIBRARY_COLUMNS) + 3,
|
||||||
|
column: CLASS_CONTENT_COLUMN + (index % ABILITY_LIBRARY_COLUMNS),
|
||||||
|
abilityId: ability.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
if (!saving) {
|
||||||
|
entries.push({ kind: 'save', key: 'save', row: 99, column: CLASS_CONTENT_COLUMN + 4 })
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}, [abilityPageCount, activeTab, currentAbilityPage, profile.character.level, profile.classes, saving, slots, visibleAbilities])
|
||||||
|
const activeEntry = navEntries.find((entry) => entry.key === selectedNavKey)
|
||||||
|
?? navEntries.find((entry) => entry.key === `tab:${activeTab}`)
|
||||||
|
?? navEntries[0]
|
||||||
|
|
||||||
|
function selected(entryKey: string) {
|
||||||
|
return classNavActive && activeEntry?.key === entryKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function navRef(entryKey: string) {
|
||||||
|
return (node: HTMLElement | null) => {
|
||||||
|
navRefs.current[entryKey] = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.scrollTo(0, scrollRef.current)
|
window.scrollTo(0, scrollRef.current)
|
||||||
@@ -45,9 +147,15 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
setClassId(nextClass.id)
|
setClassId(nextClass.id)
|
||||||
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
||||||
setSelectedSlot(0)
|
setSelectedSlot(0)
|
||||||
|
setAbilityPage(0)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectTab(tab: CustomizeTab) {
|
||||||
|
setActiveTab(tab)
|
||||||
|
setSelectedNavKey(`tab:${tab}`)
|
||||||
|
}
|
||||||
|
|
||||||
function equipAbility(abilityId: number) {
|
function equipAbility(abilityId: number) {
|
||||||
if (slots.includes(abilityId)) {
|
if (slots.includes(abilityId)) {
|
||||||
setMessage('That ability is already equipped.')
|
setMessage('That ability is already equipped.')
|
||||||
@@ -65,6 +173,66 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveSelection(action: InputAction) {
|
||||||
|
if (!action.startsWith('navigate') || navEntries.length === 0 || !activeEntry) return
|
||||||
|
const activeIndex = Math.max(0, navEntries.findIndex((entry) => entry.key === activeEntry.key))
|
||||||
|
const candidates = navEntries
|
||||||
|
.map((entry, index) => ({ entry, index }))
|
||||||
|
.filter(({ index }) => index !== activeIndex)
|
||||||
|
.filter(({ entry }) => {
|
||||||
|
if (action === 'navigateLeft') return entry.column < activeEntry.column
|
||||||
|
if (action === 'navigateRight') return entry.column > activeEntry.column
|
||||||
|
if (action === 'navigateUp') return entry.row < activeEntry.row
|
||||||
|
return entry.row > activeEntry.row
|
||||||
|
})
|
||||||
|
if (candidates.length === 0) return
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const aPrimary = Math.abs(a.entry.row - activeEntry.row) + Math.abs(a.entry.column - activeEntry.column)
|
||||||
|
const bPrimary = Math.abs(b.entry.row - activeEntry.row) + Math.abs(b.entry.column - activeEntry.column)
|
||||||
|
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(a.entry.row - activeEntry.row)
|
||||||
|
: Math.abs(a.entry.column - activeEntry.column)
|
||||||
|
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(b.entry.row - activeEntry.row)
|
||||||
|
: Math.abs(b.entry.column - activeEntry.column)
|
||||||
|
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
||||||
|
})
|
||||||
|
setSelectedNavKey(candidates[0]?.entry.key ?? activeEntry.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEntry(entry: CustomizeNavEntry | undefined) {
|
||||||
|
if (!entry) return
|
||||||
|
if (entry.kind === 'back') onBack()
|
||||||
|
else if (entry.kind === 'tab') selectTab(entry.tab)
|
||||||
|
else if (entry.kind === 'class') {
|
||||||
|
const nextClass = profile.classes.find((candidate) => candidate.id === entry.classId)
|
||||||
|
if (nextClass) chooseClass(nextClass)
|
||||||
|
} else if (entry.kind === 'slot') setSelectedSlot(entry.slotIndex)
|
||||||
|
else if (entry.kind === 'clear') clearSlot()
|
||||||
|
else if (entry.kind === 'abilityPagePrev') setAbilityPage((page) => Math.max(0, page - 1))
|
||||||
|
else if (entry.kind === 'abilityPageNext') setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))
|
||||||
|
else if (entry.kind === 'ability') equipAbility(entry.abilityId)
|
||||||
|
else if (entry.kind === 'save') void persistChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!classNavActive) return
|
||||||
|
navRefs.current[activeEntry?.key ?? '']?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||||
|
}, [activeEntry, classNavActive])
|
||||||
|
|
||||||
|
useGameAction((action, device) => {
|
||||||
|
if (device !== 'controller' || !classNavActive) return
|
||||||
|
if (action === 'back') {
|
||||||
|
onBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'confirm') {
|
||||||
|
openEntry(activeEntry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moveSelection(action)
|
||||||
|
})
|
||||||
|
|
||||||
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||||
if (activeTab !== 'class') return null
|
if (activeTab !== 'class') return null
|
||||||
return {
|
return {
|
||||||
@@ -104,28 +272,46 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="content-screen customize-screen">
|
<section className="content-screen customize-screen" data-game-nav-active={classNavActive ? 'true' : undefined}>
|
||||||
<div className="screen-heading customize-heading">
|
<div className="screen-heading customize-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Character Workshop</p>
|
<p className="eyebrow">Character Workshop</p>
|
||||||
<h1>Customize Character</h1>
|
<h1>Customize Character</h1>
|
||||||
</div>
|
</div>
|
||||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
<button
|
||||||
|
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav={classNavActive ? 'skip' : undefined}
|
||||||
|
data-game-selected={selected('back') ? 'true' : undefined}
|
||||||
|
onClick={onBack}
|
||||||
|
onPointerDown={() => setSelectedNavKey('back')}
|
||||||
|
ref={navRef('back')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
||||||
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
|
<button
|
||||||
{([
|
className={`back-button customize-tab-back ${selected('back') ? 'game-selected' : ''}`}
|
||||||
{ key: 'equipment', label: 'Equipment' },
|
data-controller-nav={classNavActive ? 'skip' : undefined}
|
||||||
{ key: 'crafting', label: 'Crafting' },
|
data-game-selected={selected('back') ? 'true' : undefined}
|
||||||
{ key: 'talents', label: 'Talents' },
|
onClick={onBack}
|
||||||
{ key: 'class', label: 'Class' },
|
onPointerDown={() => setSelectedNavKey('back')}
|
||||||
] as const).map((tab) => (
|
type="button"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
{CUSTOMIZE_TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
aria-selected={activeTab === tab.key}
|
aria-selected={activeTab === tab.key}
|
||||||
className={activeTab === tab.key ? 'active' : ''}
|
className={`${activeTab === tab.key ? 'active' : ''} ${selected(`tab:${tab.key}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav={classNavActive ? 'skip' : undefined}
|
||||||
|
data-game-selected={selected(`tab:${tab.key}`) ? 'true' : undefined}
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => selectTab(tab.key)}
|
||||||
|
onPointerDown={() => setSelectedNavKey(`tab:${tab.key}`)}
|
||||||
|
ref={navRef(`tab:${tab.key}`)}
|
||||||
role="tab"
|
role="tab"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -168,10 +354,14 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
<p className="eyebrow">Healing Class</p>
|
<p className="eyebrow">Healing Class</p>
|
||||||
{profile.classes.map((candidate) => (
|
{profile.classes.map((candidate) => (
|
||||||
<button
|
<button
|
||||||
className={candidate.id === classId ? 'active' : ''}
|
className={`${candidate.id === classId ? 'active' : ''} ${selected(`class:${candidate.id}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`class:${candidate.id}`) ? 'true' : undefined}
|
||||||
key={candidate.id}
|
key={candidate.id}
|
||||||
onClick={() => chooseClass(candidate)}
|
onClick={() => chooseClass(candidate)}
|
||||||
style={{ '--class-color': candidate.themeColor } as React.CSSProperties}
|
onPointerDown={() => setSelectedNavKey(`class:${candidate.id}`)}
|
||||||
|
ref={navRef(`class:${candidate.id}`)}
|
||||||
|
style={{ '--class-color': candidate.themeColor } as CSSProperties}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>{candidate.name[0]}</span>
|
<span>{candidate.name[0]}</span>
|
||||||
@@ -211,9 +401,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
const ability = abilityId ? abilityMap.get(abilityId) : undefined
|
const ability = abilityId ? abilityMap.get(abilityId) : undefined
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={selectedSlot === index ? 'selected' : ''}
|
className={`${selectedSlot === index ? 'selected' : ''} ${selected(`slot:${index}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`slot:${index}`) ? 'true' : undefined}
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => setSelectedSlot(index)}
|
onClick={() => setSelectedSlot(index)}
|
||||||
|
onPointerDown={() => setSelectedNavKey(`slot:${index}`)}
|
||||||
|
ref={navRef(`slot:${index}`)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<kbd>{index + 1}</kbd>
|
<kbd>{index + 1}</kbd>
|
||||||
@@ -229,19 +423,64 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
<p className="eyebrow">Class Abilities</p>
|
<p className="eyebrow">Class Abilities</p>
|
||||||
<h2>Ability Library</h2>
|
<h2>Ability Library</h2>
|
||||||
</div>
|
</div>
|
||||||
<button className="text-button" onClick={clearSlot} type="button">Clear Selected Slot</button>
|
<div className="ability-library-actions">
|
||||||
|
{abilityPageCount > 1 && (
|
||||||
|
<div className="ability-library-pager">
|
||||||
|
<button
|
||||||
|
className={`text-button ${selected('ability-page-prev') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('ability-page-prev') ? 'true' : undefined}
|
||||||
|
disabled={currentAbilityPage === 0}
|
||||||
|
onClick={() => setAbilityPage((page) => Math.max(0, page - 1))}
|
||||||
|
onPointerDown={() => setSelectedNavKey('ability-page-prev')}
|
||||||
|
ref={navRef('ability-page-prev')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span>{currentAbilityPage + 1}/{abilityPageCount}</span>
|
||||||
|
<button
|
||||||
|
className={`text-button ${selected('ability-page-next') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('ability-page-next') ? 'true' : undefined}
|
||||||
|
disabled={currentAbilityPage >= abilityPageCount - 1}
|
||||||
|
onClick={() => setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))}
|
||||||
|
onPointerDown={() => setSelectedNavKey('ability-page-next')}
|
||||||
|
ref={navRef('ability-page-next')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`text-button ${selected('clear-slot') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('clear-slot') ? 'true' : undefined}
|
||||||
|
onClick={clearSlot}
|
||||||
|
onPointerDown={() => setSelectedNavKey('clear-slot')}
|
||||||
|
ref={navRef('clear-slot')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Clear Selected Slot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ability-library">
|
<div className="ability-library">
|
||||||
{gameClass.spells.map((ability) => {
|
{visibleAbilities.map((ability) => {
|
||||||
const locked = ability.unlockLevel > profile.character.level
|
const locked = ability.unlockLevel > profile.character.level
|
||||||
const equipped = slots.includes(ability.id)
|
const equipped = slots.includes(ability.id)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''}`}
|
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''} ${selected(`ability:${ability.id}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`ability:${ability.id}`) ? 'true' : undefined}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
key={ability.id}
|
key={ability.id}
|
||||||
onClick={() => equipAbility(ability.id)}
|
onClick={() => equipAbility(ability.id)}
|
||||||
|
onPointerDown={() => setSelectedNavKey(`ability:${ability.id}`)}
|
||||||
|
ref={navRef(`ability:${ability.id}`)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>{locked ? 'L' : ability.glyph}</span>
|
<span>{locked ? 'L' : ability.glyph}</span>
|
||||||
@@ -258,9 +497,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
<div className="save-row">
|
<div className="save-row">
|
||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className={`primary-button ${selected('save') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('save') ? 'true' : undefined}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={persistChanges}
|
onClick={persistChanges}
|
||||||
|
onPointerDown={() => setSelectedNavKey('save')}
|
||||||
|
ref={navRef('save')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{saving ? 'Saving...' : 'Save Character'}
|
{saving ? 'Saving...' : 'Save Character'}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export function EquipmentScreen({
|
|||||||
const [upgrading, setUpgrading] = useState(false)
|
const [upgrading, setUpgrading] = useState(false)
|
||||||
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
||||||
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
|
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
|
||||||
|
const activeEquipmentTab = mode ?? equipmentTab
|
||||||
const [inventoryPage, setInventoryPage] = useState(0)
|
const [inventoryPage, setInventoryPage] = useState(0)
|
||||||
const [recipePage, setRecipePage] = useState(0)
|
const [recipePage, setRecipePage] = useState(0)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
@@ -97,10 +98,6 @@ export function EquipmentScreen({
|
|||||||
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
||||||
firstRecipe?.id ?? null,
|
firstRecipe?.id ?? null,
|
||||||
)
|
)
|
||||||
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
|
||||||
const selectedRecipeRequiresUpgrade = selectedRecipe
|
|
||||||
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
|
|
||||||
: false
|
|
||||||
const selectedItemRecipe = selectedItem
|
const selectedItemRecipe = selectedItem
|
||||||
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -132,9 +129,10 @@ export function EquipmentScreen({
|
|||||||
1,
|
1,
|
||||||
Math.ceil(visibleInventory.length / EQUIPMENT_LIST_PAGE_SIZE),
|
Math.ceil(visibleInventory.length / EQUIPMENT_LIST_PAGE_SIZE),
|
||||||
)
|
)
|
||||||
|
const activeInventoryPage = Math.min(inventoryPage, inventoryPageCount - 1)
|
||||||
const inventoryPageItems = visibleInventory.slice(
|
const inventoryPageItems = visibleInventory.slice(
|
||||||
inventoryPage * EQUIPMENT_LIST_PAGE_SIZE,
|
activeInventoryPage * EQUIPMENT_LIST_PAGE_SIZE,
|
||||||
(inventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE,
|
(activeInventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE,
|
||||||
)
|
)
|
||||||
|
|
||||||
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
||||||
@@ -172,42 +170,28 @@ export function EquipmentScreen({
|
|||||||
1,
|
1,
|
||||||
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
|
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
|
||||||
)
|
)
|
||||||
|
const activeRecipePage = Math.min(recipePage, recipePageCount - 1)
|
||||||
const recipePageItems = filteredRecipes.slice(
|
const recipePageItems = filteredRecipes.slice(
|
||||||
recipePage * CRAFTING_LIST_PAGE_SIZE,
|
activeRecipePage * CRAFTING_LIST_PAGE_SIZE,
|
||||||
(recipePage + 1) * CRAFTING_LIST_PAGE_SIZE,
|
(activeRecipePage + 1) * CRAFTING_LIST_PAGE_SIZE,
|
||||||
)
|
)
|
||||||
|
const activeSelectedRecipeId = filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)
|
||||||
|
? selectedRecipeId
|
||||||
|
: filteredRecipes[0]?.id ?? null
|
||||||
|
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === activeSelectedRecipeId)
|
||||||
|
const selectedRecipeRequiresUpgrade = selectedRecipe
|
||||||
|
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
|
||||||
|
: false
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.scrollTo(0, scrollRef.current)
|
window.scrollTo(0, scrollRef.current)
|
||||||
}, [profile])
|
}, [profile])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInventoryPage((current) => Math.min(current, inventoryPageCount - 1))
|
if (activeEquipmentTab === 'crafting') {
|
||||||
}, [inventoryPageCount])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRecipePage((current) => Math.min(current, recipePageCount - 1))
|
|
||||||
}, [recipePageCount])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (filteredRecipes.length === 0) {
|
|
||||||
setSelectedRecipeId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)) {
|
|
||||||
setSelectedRecipeId(filteredRecipes[0].id)
|
|
||||||
}
|
|
||||||
}, [filteredRecipes, selectedRecipeId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (equipmentTab === 'crafting') {
|
|
||||||
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
|
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [equipmentTab])
|
}, [activeEquipmentTab, onUpdated])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode) setEquipmentTab(mode)
|
|
||||||
}, [mode])
|
|
||||||
|
|
||||||
function saveScroll() {
|
function saveScroll() {
|
||||||
scrollRef.current = window.scrollY
|
scrollRef.current = window.scrollY
|
||||||
@@ -330,7 +314,7 @@ export function EquipmentScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workshopState = useMemo<DualScreenWorkshopState>(() => {
|
const workshopState = useMemo<DualScreenWorkshopState>(() => {
|
||||||
if (equipmentTab === 'crafting') {
|
if (activeEquipmentTab === 'crafting') {
|
||||||
if (!selectedRecipe) {
|
if (!selectedRecipe) {
|
||||||
return {
|
return {
|
||||||
mode: 'crafting',
|
mode: 'crafting',
|
||||||
@@ -416,7 +400,7 @@ export function EquipmentScreen({
|
|||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
|
}, [activeEquipmentTab, comparisonItem, selectedItem, selectedRecipe, upgradeRecipe])
|
||||||
|
|
||||||
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
||||||
|
|
||||||
@@ -449,14 +433,14 @@ export function EquipmentScreen({
|
|||||||
{showModeTabs && (
|
{showModeTabs && (
|
||||||
<nav className="equipment-tabs">
|
<nav className="equipment-tabs">
|
||||||
<button
|
<button
|
||||||
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
className={`equipment-tab ${activeEquipmentTab === 'equipment' ? 'active' : ''}`}
|
||||||
onClick={() => setEquipmentTab('equipment')}
|
onClick={() => setEquipmentTab('equipment')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Equipment
|
Equipment
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
|
className={`equipment-tab ${activeEquipmentTab === 'crafting' ? 'active' : ''}`}
|
||||||
onClick={() => setEquipmentTab('crafting')}
|
onClick={() => setEquipmentTab('crafting')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -465,7 +449,7 @@ export function EquipmentScreen({
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{equipmentTab === 'equipment' ? (
|
{activeEquipmentTab === 'equipment' ? (
|
||||||
<>
|
<>
|
||||||
<section className="item-comparison">
|
<section className="item-comparison">
|
||||||
{selectedItem ? (
|
{selectedItem ? (
|
||||||
@@ -577,11 +561,11 @@ export function EquipmentScreen({
|
|||||||
</div>
|
</div>
|
||||||
{visibleInventory.length > EQUIPMENT_LIST_PAGE_SIZE && (
|
{visibleInventory.length > EQUIPMENT_LIST_PAGE_SIZE && (
|
||||||
<ListPager
|
<ListPager
|
||||||
label={`Page ${inventoryPage + 1} / ${inventoryPageCount}`}
|
label={`Page ${activeInventoryPage + 1} / ${inventoryPageCount}`}
|
||||||
onNext={() => setInventoryPage((current) => Math.min(inventoryPageCount - 1, current + 1))}
|
onNext={() => setInventoryPage(Math.min(inventoryPageCount - 1, activeInventoryPage + 1))}
|
||||||
onPrevious={() => setInventoryPage((current) => Math.max(0, current - 1))}
|
onPrevious={() => setInventoryPage(Math.max(0, activeInventoryPage - 1))}
|
||||||
nextDisabled={inventoryPage >= inventoryPageCount - 1}
|
nextDisabled={activeInventoryPage >= inventoryPageCount - 1}
|
||||||
previousDisabled={inventoryPage <= 0}
|
previousDisabled={activeInventoryPage <= 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -666,7 +650,7 @@ export function EquipmentScreen({
|
|||||||
<EquipmentHeading
|
<EquipmentHeading
|
||||||
eyebrow="Available Gear"
|
eyebrow="Available Gear"
|
||||||
title={slotFilter === 'all' ? 'Craftable Gear' : SLOT_LABELS[slotFilter]}
|
title={slotFilter === 'all' ? 'Craftable Gear' : SLOT_LABELS[slotFilter]}
|
||||||
detail={`Page ${recipePage + 1}/${recipePageCount}`}
|
detail={`Page ${activeRecipePage + 1}/${recipePageCount}`}
|
||||||
/>
|
/>
|
||||||
{filteredRecipes.length === 0 ? (
|
{filteredRecipes.length === 0 ? (
|
||||||
<p className="inventory-empty">No recipes match filters.</p>
|
<p className="inventory-empty">No recipes match filters.</p>
|
||||||
@@ -674,7 +658,7 @@ export function EquipmentScreen({
|
|||||||
<div className="crafting-list">
|
<div className="crafting-list">
|
||||||
{recipePageItems.map((recipe) => (
|
{recipePageItems.map((recipe) => (
|
||||||
<button
|
<button
|
||||||
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
|
className={`${activeSelectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
|
||||||
key={recipe.id}
|
key={recipe.id}
|
||||||
onClick={() => setSelectedRecipeId(recipe.id)}
|
onClick={() => setSelectedRecipeId(recipe.id)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -696,11 +680,11 @@ export function EquipmentScreen({
|
|||||||
)}
|
)}
|
||||||
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
|
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
|
||||||
<ListPager
|
<ListPager
|
||||||
label={`Page ${recipePage + 1} / ${recipePageCount}`}
|
label={`Page ${activeRecipePage + 1} / ${recipePageCount}`}
|
||||||
onNext={() => setRecipePage((current) => Math.min(recipePageCount - 1, current + 1))}
|
onNext={() => setRecipePage(Math.min(recipePageCount - 1, activeRecipePage + 1))}
|
||||||
onPrevious={() => setRecipePage((current) => Math.max(0, current - 1))}
|
onPrevious={() => setRecipePage(Math.max(0, activeRecipePage - 1))}
|
||||||
nextDisabled={recipePage >= recipePageCount - 1}
|
nextDisabled={activeRecipePage >= recipePageCount - 1}
|
||||||
previousDisabled={recipePage <= 0}
|
previousDisabled={activeRecipePage <= 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="crafting-action-row">
|
<div className="crafting-action-row">
|
||||||
@@ -748,7 +732,7 @@ export function EquipmentScreen({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{equipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
|
{activeEquipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
|
||||||
<section className="set-bonus-panel">
|
<section className="set-bonus-panel">
|
||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -784,11 +768,11 @@ export function EquipmentScreen({
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
return <div className={`equipment-screen embedded-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
|
return <div className={`equipment-screen embedded-screen ${activeEquipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`content-screen equipment-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
|
<section className={`content-screen equipment-screen ${activeEquipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
|
||||||
{content}
|
{content}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
||||||
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
|
import { useGameAction, type InputAction } from '../input'
|
||||||
|
|
||||||
|
type HunterProfileScreenProps = {
|
||||||
|
profile: CharacterProfile
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type BossEntry = {
|
||||||
|
encounter: DungeonEncounter
|
||||||
|
dungeonId: number
|
||||||
|
dungeonName: string
|
||||||
|
contentType: 'dungeon' | 'raid'
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollectionItem = {
|
||||||
|
key: string
|
||||||
|
glyph: string
|
||||||
|
name: string
|
||||||
|
chance: string
|
||||||
|
quantity: number
|
||||||
|
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HunterProfileTab = 'stats' | 'collection'
|
||||||
|
|
||||||
|
type HunterProfileNavEntry =
|
||||||
|
| { kind: 'back'; key: string; row: number; column: number }
|
||||||
|
| { kind: 'tab'; key: string; row: number; column: number; tab: HunterProfileTab }
|
||||||
|
| { kind: 'stat'; key: string; row: number; column: number; index: number }
|
||||||
|
| { kind: 'boss'; key: string; row: number; column: number; bossId: number }
|
||||||
|
| { kind: 'item'; key: string; row: number; column: number; itemKey: string }
|
||||||
|
|
||||||
|
const HUNTER_PROFILE_DROP_COLUMNS = 6
|
||||||
|
|
||||||
|
function bossInitials(name: string) {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropChanceLabel(chance: number) {
|
||||||
|
if (chance <= 0) return 'Unavailable'
|
||||||
|
const denominator = Math.round(1 / chance)
|
||||||
|
if (denominator >= 10) return `1 in ${denominator}`
|
||||||
|
return `${Math.round(chance * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function purplePetKey(encounterId: number) {
|
||||||
|
return `purple:${encounterId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<HunterProfileTab>('stats')
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(1)
|
||||||
|
const screenRef = useRef<HTMLElement | null>(null)
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
|
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
|
||||||
|
dungeon.encounters
|
||||||
|
.filter((encounter) => encounter.isBoss)
|
||||||
|
.map((encounter) => ({
|
||||||
|
encounter,
|
||||||
|
dungeonId: dungeon.id,
|
||||||
|
dungeonName: dungeon.name,
|
||||||
|
contentType: dungeon.contentType,
|
||||||
|
})),
|
||||||
|
), [profile.dungeons])
|
||||||
|
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
|
||||||
|
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
|
||||||
|
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
|
||||||
|
const bossKills = useMemo(() => profile.hunterStats?.bossKills ?? {}, [profile.hunterStats?.bossKills])
|
||||||
|
const bossPets = useMemo(() => profile.hunterStats?.bossPets ?? {}, [profile.hunterStats?.bossPets])
|
||||||
|
const inventoryQuantities = useMemo(
|
||||||
|
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
|
||||||
|
[profile.inventory],
|
||||||
|
)
|
||||||
|
const totalBossKills = bosses.reduce((total, boss) => total + (bossKills[String(boss.encounter.id)] ?? 0), 0)
|
||||||
|
const mostKilledBoss = bosses.reduce<BossEntry | null>((best, boss) => {
|
||||||
|
if (!best) return boss
|
||||||
|
return (bossKills[String(boss.encounter.id)] ?? 0) > (bossKills[String(best.encounter.id)] ?? 0)
|
||||||
|
? boss
|
||||||
|
: best
|
||||||
|
}, null)
|
||||||
|
const matchesPlayed = profile.hunterStats?.pvpMatchesPlayed ?? 0
|
||||||
|
const matchesWon = profile.hunterStats?.pvpMatchesWon ?? 0
|
||||||
|
const winRate = matchesPlayed > 0 ? Math.round((matchesWon / matchesPlayed) * 100) : 0
|
||||||
|
const collectionItems = useMemo<CollectionItem[]>(() => {
|
||||||
|
if (!selectedBoss) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: `kills:${selectedBoss.encounter.id}`,
|
||||||
|
glyph: 'K',
|
||||||
|
name: 'Boss Kills',
|
||||||
|
chance: 'Defeats',
|
||||||
|
quantity: bossKills[String(selectedBoss.encounter.id)] ?? 0,
|
||||||
|
rarity: 'stat',
|
||||||
|
source: selectedBoss.encounter.enemyName,
|
||||||
|
},
|
||||||
|
...selectedBoss.encounter.lootTables.map((drop) => ({
|
||||||
|
key: `drop:${drop.difficultyId}:${drop.id}`,
|
||||||
|
glyph: drop.glyph,
|
||||||
|
name: drop.name,
|
||||||
|
chance: dropChanceLabel(drop.dropChance),
|
||||||
|
quantity: inventoryQuantities.get(drop.id) ?? 0,
|
||||||
|
rarity: drop.rarity,
|
||||||
|
source: selectedBoss.encounter.enemyName,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
key: `pet:${selectedBoss.encounter.id}`,
|
||||||
|
glyph: '*',
|
||||||
|
name: `${selectedBoss.encounter.enemyName} Pet`,
|
||||||
|
chance: '1 in 500',
|
||||||
|
quantity: bossPets[String(selectedBoss.encounter.id)] ?? 0,
|
||||||
|
rarity: 'legendary',
|
||||||
|
source: selectedBoss.encounter.enemyName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `purple-pet:${selectedBoss.encounter.id}`,
|
||||||
|
glyph: 'P',
|
||||||
|
name: `Purple ${selectedBoss.encounter.enemyName} Pet`,
|
||||||
|
chance: '1 in 500',
|
||||||
|
quantity: bossPets[purplePetKey(selectedBoss.encounter.id)] ?? 0,
|
||||||
|
rarity: 'epic',
|
||||||
|
source: `${selectedBoss.encounter.enemyName} Roguelike`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
|
||||||
|
const statTiles = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'stat:total-kills',
|
||||||
|
label: 'Total Boss Kills',
|
||||||
|
value: totalBossKills,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stat:most-killed',
|
||||||
|
label: 'Most Killed Boss',
|
||||||
|
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None',
|
||||||
|
detail: totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stat:pvp-matches',
|
||||||
|
label: 'PvP Matches',
|
||||||
|
value: matchesPlayed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stat:pvp-wins',
|
||||||
|
label: 'PvP Wins',
|
||||||
|
value: matchesWon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stat:pvp-win-rate',
|
||||||
|
label: 'PvP Win Rate',
|
||||||
|
value: `${winRate}%`,
|
||||||
|
},
|
||||||
|
], [bossKills, matchesPlayed, matchesWon, mostKilledBoss, totalBossKills, winRate])
|
||||||
|
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
|
||||||
|
const navEntries = useMemo<HunterProfileNavEntry[]>(() => {
|
||||||
|
const entries: HunterProfileNavEntry[] = [
|
||||||
|
{ kind: 'back', key: 'back', row: 0, column: 0 },
|
||||||
|
{ kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' },
|
||||||
|
{ kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' },
|
||||||
|
]
|
||||||
|
if (activeTab === 'stats') {
|
||||||
|
entries.push(...statTiles.map((tile, index) => ({
|
||||||
|
kind: 'stat' as const,
|
||||||
|
key: tile.key,
|
||||||
|
row: 1,
|
||||||
|
column: index,
|
||||||
|
index,
|
||||||
|
})))
|
||||||
|
} else {
|
||||||
|
entries.push(
|
||||||
|
...bosses.map((boss, index) => ({
|
||||||
|
kind: 'boss' as const,
|
||||||
|
key: `boss:${boss.encounter.id}`,
|
||||||
|
row: index + 1,
|
||||||
|
column: 0,
|
||||||
|
bossId: boss.encounter.id,
|
||||||
|
})),
|
||||||
|
...collectionItems.map((item, index) => ({
|
||||||
|
kind: 'item' as const,
|
||||||
|
key: `item:${item.key}`,
|
||||||
|
row: Math.floor(index / HUNTER_PROFILE_DROP_COLUMNS) + 1,
|
||||||
|
column: (index % HUNTER_PROFILE_DROP_COLUMNS) + 1,
|
||||||
|
itemKey: item.key,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}, [activeTab, bosses, collectionItems, statTiles])
|
||||||
|
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
|
||||||
|
const activeEntry = navEntries[activeIndex]
|
||||||
|
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||||
|
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
|
||||||
|
return {
|
||||||
|
mode: 'collection',
|
||||||
|
title: focusedItem.name,
|
||||||
|
subtitle: selectedBoss.encounter.enemyName,
|
||||||
|
summary: `Owned x${focusedItem.quantity}`,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
glyph: focusedItem.glyph,
|
||||||
|
title: 'Drop Rate',
|
||||||
|
meta: focusedItem.chance,
|
||||||
|
detail: focusedItem.source,
|
||||||
|
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Boss Kills',
|
||||||
|
meta: `${bossKills[String(selectedBoss.encounter.id)] ?? 0}`,
|
||||||
|
detail: selectedBoss.dungeonName,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [activeTab, bossKills, focusedItem, selectedBoss])
|
||||||
|
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
|
||||||
|
|
||||||
|
function selected(entryKey: string) {
|
||||||
|
return activeEntry?.key === entryKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectKey(entryKey: string) {
|
||||||
|
const index = navEntries.findIndex((entry) => entry.key === entryKey)
|
||||||
|
if (index >= 0) setSelectedIndex(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTab(tab: HunterProfileTab) {
|
||||||
|
setActiveTab(tab)
|
||||||
|
setSelectedIndex(tab === 'stats' ? 1 : 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBoss(bossId: number) {
|
||||||
|
setSelectedBossId(bossId)
|
||||||
|
setFocusedItemKey(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelection(action: InputAction) {
|
||||||
|
if (!action.startsWith('navigate') || navEntries.length === 0) return
|
||||||
|
setSelectedIndex((current) => {
|
||||||
|
const bounded = Math.min(current, navEntries.length - 1)
|
||||||
|
const active = navEntries[bounded]
|
||||||
|
if (!active) return 0
|
||||||
|
const candidates = navEntries
|
||||||
|
.map((entry, index) => ({ entry, index }))
|
||||||
|
.filter(({ index }) => index !== bounded)
|
||||||
|
.filter(({ entry }) => {
|
||||||
|
if (action === 'navigateLeft') return entry.column < active.column
|
||||||
|
if (action === 'navigateRight') return entry.column > active.column
|
||||||
|
if (action === 'navigateUp') return entry.row < active.row
|
||||||
|
return entry.row > active.row
|
||||||
|
})
|
||||||
|
if (candidates.length === 0) return bounded
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
|
||||||
|
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
|
||||||
|
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(a.entry.row - active.row)
|
||||||
|
: Math.abs(a.entry.column - active.column)
|
||||||
|
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(b.entry.row - active.row)
|
||||||
|
: Math.abs(b.entry.column - active.column)
|
||||||
|
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
||||||
|
})
|
||||||
|
return candidates[0]?.index ?? bounded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEntry(entry: HunterProfileNavEntry | undefined) {
|
||||||
|
if (!entry) return
|
||||||
|
if (entry.kind === 'back') onBack()
|
||||||
|
else if (entry.kind === 'tab') selectTab(entry.tab)
|
||||||
|
else if (entry.kind === 'boss') selectBoss(entry.bossId)
|
||||||
|
else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeEntry) return
|
||||||
|
screenRef.current
|
||||||
|
?.querySelector<HTMLElement>('[data-game-selected="true"]')
|
||||||
|
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||||
|
}, [activeEntry])
|
||||||
|
|
||||||
|
useGameAction((action, inputDevice) => {
|
||||||
|
if (inputDevice !== 'controller') return
|
||||||
|
if (action === 'back') {
|
||||||
|
onBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'confirm') {
|
||||||
|
openEntry(activeEntry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moveSelection(action)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="content-screen hunter-profile-screen" data-game-nav-active="true" ref={screenRef}>
|
||||||
|
<div className="screen-heading hunter-profile-heading">
|
||||||
|
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
|
||||||
|
<button
|
||||||
|
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''} ${selected('tab:stats') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('tab:stats') ? 'true' : undefined}
|
||||||
|
onClick={() => selectTab('stats')}
|
||||||
|
onPointerDown={() => selectKey('tab:stats')}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'stats'}
|
||||||
|
>
|
||||||
|
Stats
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''} ${selected('tab:collection') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('tab:collection') ? 'true' : undefined}
|
||||||
|
onClick={() => selectTab('collection')}
|
||||||
|
onPointerDown={() => selectKey('tab:collection')}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'collection'}
|
||||||
|
>
|
||||||
|
Collection Log
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected('back') ? 'true' : undefined}
|
||||||
|
onClick={onBack}
|
||||||
|
onPointerDown={() => selectKey('back')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'stats' && (
|
||||||
|
<div className="hunter-stat-grid">
|
||||||
|
{statTiles.map((tile) => (
|
||||||
|
<article
|
||||||
|
className={`hunter-stat-tile ${selected(tile.key) ? 'game-selected' : ''}`}
|
||||||
|
data-game-selected={selected(tile.key) ? 'true' : undefined}
|
||||||
|
key={tile.key}
|
||||||
|
onPointerDown={() => selectKey(tile.key)}
|
||||||
|
>
|
||||||
|
<span>{tile.label}</span>
|
||||||
|
<strong>{tile.value}</strong>
|
||||||
|
{tile.detail && <small>{tile.detail}</small>}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'collection' && (
|
||||||
|
<div className="collection-log-layout">
|
||||||
|
<div className="collection-boss-list" aria-label="Bosses">
|
||||||
|
{bosses.map((boss) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''} ${selected(`boss:${boss.encounter.id}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`boss:${boss.encounter.id}`) ? 'true' : undefined}
|
||||||
|
key={boss.encounter.id}
|
||||||
|
onClick={() => selectBoss(boss.encounter.id)}
|
||||||
|
onPointerDown={() => selectKey(`boss:${boss.encounter.id}`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{bossInitials(boss.encounter.enemyName)}</span>
|
||||||
|
<strong>{boss.encounter.enemyName}</strong>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedBoss && (
|
||||||
|
<article className="collection-boss-detail">
|
||||||
|
<div className="collection-drop-list">
|
||||||
|
{collectionItems.map((item) => (
|
||||||
|
<button
|
||||||
|
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'} ${selected(`item:${item.key}`) ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={selected(`item:${item.key}`) ? 'true' : undefined}
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => setFocusedItemKey(item.key)}
|
||||||
|
onFocus={() => setFocusedItemKey(item.key)}
|
||||||
|
onPointerDown={() => selectKey(`item:${item.key}`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{item.glyph}</span>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<small>{item.chance}</small>
|
||||||
|
<b>x{item.quantity}</b>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { memo, type ElementType, type ReactNode } from 'react'
|
||||||
|
import type { ControllerIconStyle } from '../input'
|
||||||
|
import type { PartyMember } from '../game'
|
||||||
|
import {
|
||||||
|
effectiveMaxHealth,
|
||||||
|
formatEffectTime,
|
||||||
|
memberHotEffects,
|
||||||
|
} from '../combat/rules'
|
||||||
|
import { ControllerBindingLabel } from './ControllerIcons'
|
||||||
|
import { barFillStyle } from './barStyles'
|
||||||
|
|
||||||
|
export type FloatingCombatText = {
|
||||||
|
id: number
|
||||||
|
memberId: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberEffectMode = 'full' | 'compact' | 'timed-basic'
|
||||||
|
|
||||||
|
const MemberEffects = memo(function MemberEffects({
|
||||||
|
member,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
member: PartyMember
|
||||||
|
mode: MemberEffectMode
|
||||||
|
}) {
|
||||||
|
if (mode === 'compact') {
|
||||||
|
return (
|
||||||
|
<div className="member-effects">
|
||||||
|
{memberHotEffects(member).map((effect) => (
|
||||||
|
<span className="buff" key={effect.id}>{effect.label}</span>
|
||||||
|
))}
|
||||||
|
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
|
||||||
|
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'timed-basic') {
|
||||||
|
return (
|
||||||
|
<div className="member-effects">
|
||||||
|
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
|
||||||
|
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
|
||||||
|
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
|
||||||
|
{(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
|
||||||
|
{(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
|
||||||
|
{(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="member-effects">
|
||||||
|
{memberHotEffects(member).map((effect) => (
|
||||||
|
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
|
||||||
|
))}
|
||||||
|
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
|
||||||
|
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
|
||||||
|
{(member.bounceHeals ?? []).map((effect) => (
|
||||||
|
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
|
||||||
|
))}
|
||||||
|
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
|
||||||
|
{(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
|
||||||
|
{(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
|
||||||
|
{(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const PartyMemberFrame = memo(function PartyMemberFrame({
|
||||||
|
as,
|
||||||
|
member,
|
||||||
|
selected = false,
|
||||||
|
baseClassName = 'party-member',
|
||||||
|
selectedClassName = 'selected',
|
||||||
|
deadClassName = 'dead',
|
||||||
|
effectMode = 'full',
|
||||||
|
floatingTexts = [],
|
||||||
|
showTargetMarker = false,
|
||||||
|
showHeaderHealth = true,
|
||||||
|
showHealthText = false,
|
||||||
|
targetBinding,
|
||||||
|
controllerIconStyle,
|
||||||
|
onSelect,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
as?: ElementType
|
||||||
|
member: PartyMember
|
||||||
|
selected?: boolean
|
||||||
|
baseClassName?: string
|
||||||
|
selectedClassName?: string
|
||||||
|
deadClassName?: string
|
||||||
|
effectMode?: MemberEffectMode
|
||||||
|
floatingTexts?: FloatingCombatText[]
|
||||||
|
showTargetMarker?: boolean
|
||||||
|
showHeaderHealth?: boolean
|
||||||
|
showHealthText?: boolean
|
||||||
|
targetBinding?: string | null
|
||||||
|
controllerIconStyle?: ControllerIconStyle
|
||||||
|
onSelect?: (id: string) => void
|
||||||
|
children?: ReactNode
|
||||||
|
}) {
|
||||||
|
const Component = as ?? (onSelect ? 'button' : 'div')
|
||||||
|
const maxHealth = effectiveMaxHealth(member)
|
||||||
|
const className = [
|
||||||
|
baseClassName,
|
||||||
|
selected ? selectedClassName : '',
|
||||||
|
member.health <= 0 ? deadClassName : '',
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
const action = targetBinding ? {
|
||||||
|
binding: targetBinding,
|
||||||
|
iconStyle: controllerIconStyle ?? 'playstation',
|
||||||
|
} : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={className}
|
||||||
|
data-party-member-id={member.id}
|
||||||
|
onClick={onSelect ? () => onSelect(member.id) : undefined}
|
||||||
|
aria-pressed={onSelect ? selected : undefined}
|
||||||
|
type={Component === 'button' ? 'button' : undefined}
|
||||||
|
>
|
||||||
|
{showTargetMarker && (
|
||||||
|
<span className="target-marker" aria-hidden="true">
|
||||||
|
<i />
|
||||||
|
Target
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="member-header">
|
||||||
|
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
||||||
|
<strong>{member.name}</strong>
|
||||||
|
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
|
||||||
|
</div>
|
||||||
|
<div className="bar member-health">
|
||||||
|
<span style={barFillStyle((member.health / maxHealth) * 100)} />
|
||||||
|
{member.shield > 0 && <i style={barFillStyle((member.shield / maxHealth) * 100, 'right')} />}
|
||||||
|
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
|
||||||
|
</div>
|
||||||
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
|
{floatingTexts.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
||||||
|
</div>
|
||||||
|
{action && (
|
||||||
|
<div className="member-target-key">
|
||||||
|
<ControllerBindingLabel
|
||||||
|
binding={action.binding}
|
||||||
|
iconStyle={action.iconStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MemberEffects member={member} mode={effectMode} />
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
)
|
||||||
|
})
|
||||||