Release Healer Man 0.1.29

This commit is contained in:
phenom committed 2026-09-11 14:26:15 -04:00
1 parent 8c9809d3e0
commit 544c1bee91
120 files changed
+4363 -1036

No files matched your search

+110
View File
@@ -2048,3 +2048,113 @@ Vite's existing large-chunk advisory remains informational.
`playtest-artifacts/2026-09-09-arena-loading/`. The existing large-bundle
advisory remains. This verifies the local browser build, not a deployment
or an Android package.
# 2026-09-10 - arena character and gameplay integration
- Replaced the limited arena spell interpreter with a private bundle of the
existing character combat engine. Offline practice and server matches share
casting, channels, resources, auras, pets, talents and equipment mechanics.
Each actor/match keeps its own state; opening panels does not run a second
client simulation or overwrite dungeon state.
- The controlled character uses CharacterModel with saved appearance, gear,
class, specialization, talents and bindings. Other participants also use
character models. The HUD displays actual resources, runes, combo points,
cast/channel status, cooldowns, buffs, pet commands and PC/controller labels.
Spellbook, character, talents, bindings, keybinds, map and options work in
arena. Equipment and talents remain locked to the entered match loadout.
- Fixed Keybinds opening with a shortened custom PC bar: missing slots now
display as empty instead of crashing the React tree. Live checks opened
Keybinds, Character, Talents and Options; the four-slot Q/E/R/T bar cast
Flash Heal and Power Word: Shield with resource/cast/aura feedback.
- Fixed NaN facing, spectator-camera elevation, obstructed starting positions
and camera collision. Shared right-button drag ends on release, blur, lost
capture, menus and teardown without pointer lock. Movement, jump, targeting,
ground placement, feedback, animation and combat effects use arena state.
- Authority uses all collision chunks and navigation triangles. Removed
invisible static proxy walls. All 3v3 starts are grounded, and bots follow
connected routes around walls/ramps. Shortcuts stay on level ground to avoid
falling below waypoints. Detailed scenes use a shaped support surface; the
loading/error fallback now matches collision instead of using a flat box.
- Black Rook's old navigation/anchors were on terrain above the arena. Rebuilt
against restored collision, selected its connected interior component (783
triangles), and placed both starts on the floor near Y=-28.3. Updated the
asset metadata and generator to stop restoring the old terrain navigation.
- Added PvP defender stats, shields, resource drains, pushback, school locks
and control diminishing returns. Fixed duplicate control entries counting
twice, aligned aura/control duration, and made dispels remove movement
restrictions. Dispel Magic supports friends and enemies. Healers can heal
allied pets; melee bots start weapon attacks to generate rage.
- Automated checks: 38 server arena tests and 160 distinct avatar, input,
camera, arena character/rules, combat store and ability catalog tests.
Coverage includes channels/cancellation, runes, combos, pet orders/damage/
healing, interrupts, absorbs, dispels, DR, ground spells, mana burns, RoM
secondary resources, dampening, isolation, matchmaking and match results.
Both-direction walks between team starts pass across all eight arenas.
- Live checks use an isolated in-memory level-80 priest, ArenaPriest, without
changing the user's roster. Warnorken showed its interior, the selected
character and saved spell labels. Movement changed both actor and camera.
Bots traversed, cast spells, reduced health and settled a defeat when idle.
No detailed-asset warning appeared. Evidence uses the arena-complete prefix
under playtest-artifacts; earlier Nagrand/custom-bar evidence retains the
arena-player prefix. The drag probe uses installed input code with scripted
right-button events; rotation changes while held, stops after release, and
pointerLockElement remains null. This is not a physical-device test.
- Final screenshots show the Warnorken interior and character/action bar,
Black Rook's corrected interior floor, the roster body/staff, and the
character/party after returning to Wailing Caverns. Files:
arena-complete-warnorken.png, arena-complete-black-rook.png,
arena-complete-roster.png, arena-complete-dungeon.png and
arena-complete-keybinds.png in playtest-artifacts.
- Production build and release checks passed, with the existing bundle-size
advisory. Run npm run arena:abilities:build after catalog/asset changes;
prebuild also regenerates the engine and geometry. Verification is local
offline practice plus authoritative server tests. Hosted online PvP,
deployment and Android hardware were not exercised.
# 2026-09-10 — profile keybinds and movement-conflict recovery
- Added Pause / Options → Keybinds with 133 actions, PC and Controller tabs,
search/categories, capture and touch selectors, explicit conflict replacement,
clear/reset controls, and Restore movement. Desktop defaults to PC; mobile
and native Android default to Controller. Both tabs remain available.
- Live offline browser check: assigning W to bar 1/button 1 named Move forward
in the conflict prompt; cancelling preserved W. Rebinding forward to T,
saving, closing, and reopening retained T. Restore movement returned it to W.
- At 390 × 844, controller selectors assigned L1 + Triangle to Jump, prompted
before clearing the old layer-2 ability binding, and retained the saved chord
after reopening. Restored the test bindings afterward. The final phone panel
starts at (0, 0), has a 443px scrolling action list, and keeps the footer within
the viewport. Desktop layout checked at 1280 × 900.
- Fixed the inherited 610px panel-height cap, mobile backdrop padding, action
button accessible names, and companion-toggle overlap found during QA.
- 89 targeted tests passed across input dispatch, mouse-drag lifecycle, analog
remapping, conflict recovery, menus, account isolation, cloud transfer, UI
imports, and dual-display synchronization. The full production build and
release candidate check passed; the existing bundle-size advisory remains.
- Controller chord conflicts also cover a modifier button’s standalone action,
preventing combinations such as View + A from opening the companion first.
- Hardware controller / Android APK validation and a physical right-button
drag were unavailable through this browser session. Drag lifecycle and null
pointer-lock assertions passed in the input tests. Browser viewport screenshots
are scaled by the in-app compositor; DOM geometry supplied the viewport checks.
## 2026-09-10 - Original spell media defaults and visual fallback
- Original spell visuals and authentic sounds are enabled by default. The original media pack is prepared before gameplay; download failure leaves generated visuals available and missing sounds silent.
- Saves using the previous generated-only default migrate once to original media while retaining quality, group visibility, mute, and volume settings. Explicit later preferences remain saved.
- Removed synthesized combat audio. Original mode now uses generated visuals when no original phase is mapped or when all original graphs/textures for that phase fail to load; usable original layers remain preferred.
- Validation: 64 targeted tests passed, covering defaults, persistence migration, media installation/retry, rendering selection, original audio playback, silence on missing/invalid audio, and existing effect lifecycles. Production TypeScript and Vite build passed after correcting the installer test mock type; prebuild content/asset checks also passed.
- Browser verification used the production CombatEffects and OriginalCombatEffect components with isolated store/media fixtures and the actual packaged Flash Heal graph/textures. The original case rendered one original emitter and zero generated emitters. Unmapped spell, graph HTTP 404, and texture HTTP 404 cases each rendered zero original emitters and one visible generated effect. Combat reset removed the fallback.
- Evidence: playtest-artifacts/spell-media-20260910/original.png, bad-graph.png, bad-texture.png, and missing.png. The first Vite preview timed out; the bundled isolated preview completed successfully. This was an effect-focused browser check, not a full dungeon or device audio audition.
- Final release check: blocked by existing trailing whitespace in server/generated/arena-combat-runtime.mjs. The files changed for spell media pass the whitespace check; the unrelated generated arena file was left intact.
## 2026-09-10 - All regular dungeons bundled with lossless shared storage
- Web and Android packaging include all 84 regular dungeons and their asset dependencies. Shared Manastorm maps are stored once while every Manastorm pack keeps its optional install flag. Manastorm-only maps remain outside the initial package.
- Packaged assets fell from 3,440,440,928 logical bytes to 1,600,690,686 stored bytes, including 179,063,519 bytes saved by sharing exact duplicate files. No source models, texture resolutions, animations, or audio were changed or generated.
- Gzip representations retain both compressed and original SHA-256/size metadata. Publishing keeps original remote objects for older clients. Browser installs verify both representations, retain compressed shared objects with small aliases, and preserve shared objects until their final pack is removed. Native optional downloads remain resumable and verify their expanded originals before activation.
- Android's packager automatically expanded and renamed .gz files; final objects use .gzip with noCompress instead. Added an APK verifier to the normal Android build workflow, checking every object, every original hash, exact object inventory, and the 84-dungeon/optional-Manastorm boundary.
- Final debug APK: 1,622,100,512 bytes. All 9,106 unique objects (9,194 logical asset references) passed verification from inside the actual APK. Verification report and test APK: artifacts/dungeon-texture-audit-20260910/apk-verification.json and healer-man-0.1.28-debug.apk.
- Validation: production TypeScript/Vite build and asset checks passed; 80 targeted content, Downloads, and avatar tests passed across the final runs, plus nine publisher/server tests. Android Java compilation and assembleDebug passed. Existing bundle-size/import advisories remain.
- Live production browser check used an isolated offline Gziphealer character. Roster and Deadmines rendered the player body, party bodies, equipped weapon silhouettes, and textured dungeon geometry using the bundled compressed objects. Downloads showed all regular dungeon packs included and 75 Manastorm rows not installed. Evidence: artifacts/dungeon-texture-audit-20260910/roster-final.png, deadmines-final.png, and downloads-dom.txt. No new console errors appeared in the final-format dungeon run.
- A concurrent build replaced the shared dist directory during the first preview, causing temporary asset 404s. Repeated visual validation against an isolated build completed successfully. Deadmines displayed a route-blocked party status at its entrance; this was a loading/rendering check, not a full dungeon traversal. Physical Android hardware was not exercised.
+2
View File
@@ -24,6 +24,8 @@ android {
versionName releaseVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Dungeon objects already carry verified lossless gzip compression.
noCompress 'gzip'
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
@@ -0,0 +1,85 @@
package com.phenomrom.healerman;
import android.content.res.AssetManager;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import com.getcapacitor.Bridge;
import org.json.JSONObject;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.GZIPInputStream;
/** Streams shared APK objects at their original logical URLs; no expanded disk copy. */
final class BundledContentAssets {
private final Bridge bridge;
private final AssetManager assets;
private JSONObject files;
BundledContentAssets(Bridge bridge) {
this.bridge = bridge;
this.assets = bridge.getContext().getAssets();
}
private synchronized JSONObject files() throws Exception {
if (files == null) {
try (InputStream input = assets.open("public/content/bootstrap-manifest.json")) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[16384];
int count;
while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count);
files = new JSONObject(output.toString("UTF-8")).getJSONObject("files");
}
}
return files;
}
WebResourceResponse intercept(WebResourceRequest request) {
if (!"GET".equals(request.getMethod()) || !bridge.getHost().equals(request.getUrl().getAuthority())
|| !bridge.getScheme().equals(request.getUrl().getScheme())) return null;
String path = request.getUrl().getPath();
if (path == null || !(path.startsWith("/assets/") || path.startsWith("/basis/") || path.startsWith("/data/"))) return null;
try {
JSONObject entry = files().optJSONObject(path);
if (entry == null) return null;
JSONObject download = entry.optJSONObject("download");
String objectUrl = (download == null ? entry : download).getString("url");
if (!objectUrl.matches("/content/bundled/[a-f0-9]{64}\\.[a-z0-9]+(?:\\.g(?:z|zip))?")) return null;
InputStream input = assets.open("public" + objectUrl);
if (download != null) {
try { input = new GZIPInputStream(input, 128 * 1024); }
catch (Exception error) { input.close(); throw error; }
}
Map<String, String> headers = new HashMap<>();
headers.put("Content-Length", String.valueOf(entry.getLong("bytes")));
headers.put("Cache-Control", "public, max-age=3600");
return new WebResourceResponse(mimeType(path), null, 200, "OK", headers, input);
} catch (Exception error) {
return new WebResourceResponse("text/plain", "UTF-8", 500, "Bundled asset unavailable", new HashMap<>(),
new ByteArrayInputStream("Bundled asset unavailable.".getBytes(StandardCharsets.UTF_8)));
}
}
private static String mimeType(String path) {
String extension = path.substring(path.lastIndexOf('.') + 1);
switch (extension) {
case "glb": return "model/gltf-binary";
case "json": return "application/json";
case "js": return "text/javascript";
case "wasm": return "application/wasm";
case "ktx2": return "image/ktx2";
case "png": return "image/png";
case "webp": return "image/webp";
case "avif": return "image/avif";
case "svg": return "image/svg+xml";
case "mp3": return "audio/mpeg";
case "ogg": return "audio/ogg";
case "wav": return "audio/wav";
case "webm": return "video/webm";
default: return "application/octet-stream";
}
}
}
@@ -8,6 +8,10 @@ import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.BridgeWebViewClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
@@ -20,6 +24,7 @@ import java.net.URI;
import java.net.URL;
import java.security.MessageDigest;
import java.util.Locale;
import java.util.zip.GZIPInputStream;
/** Streams verified, resumable content objects into Android app-private storage. */
@CapacitorPlugin(name = "ContentStorage")
@@ -27,6 +32,18 @@ public class ContentStoragePlugin extends Plugin {
private static final int BUFFER_BYTES = 128 * 1024;
private static final long PROGRESS_INTERVAL_MS = 200L;
@Override
public void load() {
BundledContentAssets bundled = new BundledContentAssets(bridge);
bridge.setWebViewClient(new BridgeWebViewClient(bridge) {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
WebResourceResponse response = bundled.intercept(request);
return response != null ? response : super.shouldInterceptRequest(view, request);
}
});
}
@PluginMethod
public void download(PluginCall call) {
execute(() -> {
@@ -36,6 +53,10 @@ public class ContentStoragePlugin extends Plugin {
String sha256 = normalizedSha(requiredString(call, "sha256"));
String extension = normalizedExtension(requiredString(call, "extension"));
long expectedBytes = numericLong(call, "bytes", -1L);
boolean compressed = "gzip".equals(call.getString("encoding"));
String transferSha = compressed ? normalizedSha(requiredString(call, "downloadSha256")) : sha256;
long transferBytes = compressed ? numericLong(call, "downloadBytes", -1L) : expectedBytes;
if (expectedBytes < 0 || transferBytes < 0) throw new IllegalArgumentException("Missing content size.");
validateRemoteUrl(url);
File destination = objectFile(sha256, extension);
@@ -47,54 +68,89 @@ public class ContentStoragePlugin extends Plugin {
throw new IllegalStateException("Unable to replace invalid cached content.");
}
File partial = new File(destination.getParentFile(), destination.getName() + ".part");
long existingBytes = partial.isFile() ? partial.length() : 0L;
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(15_000);
connection.setReadTimeout(45_000);
connection.setRequestProperty("Accept-Encoding", "identity");
if (existingBytes > 0L) connection.setRequestProperty("Range", "bytes=" + existingBytes + "-");
int status = connection.getResponseCode();
boolean resumed = existingBytes > 0L && status == HttpURLConnection.HTTP_PARTIAL;
if (status != HttpURLConnection.HTTP_OK && status != HttpURLConnection.HTTP_PARTIAL) {
connection.disconnect();
throw new IllegalStateException("Content server returned HTTP " + status + ".");
}
if (!resumed) existingBytes = 0L;
long downloadedBytes = existingBytes;
long totalBytes = expectedBytes > 0L
? expectedBytes
: existingBytes + Math.max(0L, connection.getContentLengthLong());
long lastProgressAt = 0L;
try (
InputStream input = new BufferedInputStream(connection.getInputStream(), BUFFER_BYTES);
BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(partial, resumed), BUFFER_BYTES)
) {
byte[] buffer = new byte[BUFFER_BYTES];
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
downloadedBytes += count;
long now = System.currentTimeMillis();
if (now - lastProgressAt >= PROGRESS_INTERVAL_MS) {
notifyProgress(logicalPath, downloadedBytes, totalBytes);
lastProgressAt = now;
File partial = new File(destination.getParentFile(), destination.getName() + (compressed ? "." + transferSha + ".gz" : "") + ".part");
if (!verifies(partial, transferSha, transferBytes)) {
if (partial.isFile() && partial.length() >= transferBytes && !partial.delete()) {
throw new IllegalStateException("Unable to reset an invalid partial download.");
}
long existingBytes = partial.isFile() ? partial.length() : 0L;
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(15_000);
connection.setReadTimeout(45_000);
connection.setRequestProperty("Accept-Encoding", "identity");
if (existingBytes > 0L) connection.setRequestProperty("Range", "bytes=" + existingBytes + "-");
int status = connection.getResponseCode();
boolean resumed = existingBytes > 0L && status == HttpURLConnection.HTTP_PARTIAL;
if (status != HttpURLConnection.HTTP_OK && status != HttpURLConnection.HTTP_PARTIAL) {
connection.disconnect();
throw new IllegalStateException("Content server returned HTTP " + status + ".");
}
if (status == HttpURLConnection.HTTP_PARTIAL) {
String range = connection.getHeaderField("Content-Range");
if (!resumed || range == null || !range.startsWith("bytes " + existingBytes + "-")) {
connection.disconnect();
partial.delete();
throw new IllegalStateException("Content server returned an invalid resume range.");
}
}
} finally {
connection.disconnect();
}
notifyProgress(logicalPath, downloadedBytes, totalBytes);
if (!resumed) existingBytes = 0L;
long downloadedBytes = existingBytes;
long totalBytes = transferBytes > 0L
? transferBytes
: existingBytes + Math.max(0L, connection.getContentLengthLong());
long lastProgressAt = 0L;
if (!verifies(partial, sha256, expectedBytes)) {
try (
InputStream input = new BufferedInputStream(connection.getInputStream(), BUFFER_BYTES);
BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(partial, resumed), BUFFER_BYTES)
) {
byte[] buffer = new byte[BUFFER_BYTES];
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
downloadedBytes += count;
if (downloadedBytes > transferBytes) throw new IllegalStateException("Downloaded content exceeds expected size.");
long now = System.currentTimeMillis();
if (now - lastProgressAt >= PROGRESS_INTERVAL_MS) {
notifyProgress(logicalPath, downloadedBytes, totalBytes);
lastProgressAt = now;
}
}
} finally {
connection.disconnect();
}
notifyProgress(logicalPath, downloadedBytes, totalBytes);
}
if (!verifies(partial, transferSha, transferBytes)) {
partial.delete();
throw new IllegalStateException("Downloaded content failed SHA-256 or size verification.");
}
File verified = partial;
if (compressed) {
verified = new File(destination.getParentFile(), destination.getName() + ".decoded");
try (InputStream input = new GZIPInputStream(new FileInputStream(partial), BUFFER_BYTES);
BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(verified), BUFFER_BYTES)) {
byte[] buffer = new byte[BUFFER_BYTES];
long decodedBytes = 0;
int count;
while ((count = input.read(buffer)) != -1) {
decodedBytes += count;
if (decodedBytes > expectedBytes) throw new IllegalStateException("Expanded content exceeds expected size.");
output.write(buffer, 0, count);
}
} catch (Exception error) { verified.delete(); throw error; }
if (!verifies(verified, sha256, expectedBytes)) {
verified.delete();
partial.delete();
throw new IllegalStateException("Expanded content failed SHA-256 or size verification.");
}
partial.delete();
}
if (destination.exists() && !destination.delete()) {
throw new IllegalStateException("Unable to activate downloaded content.");
}
if (!partial.renameTo(destination)) {
if (!verified.renameTo(destination)) {
throw new IllegalStateException("Unable to atomically activate downloaded content.");
}
call.resolve(fileResult(destination, false));
@@ -100,6 +100,8 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_Y), "Button12", "Button13");
addAxisTokens(currentTokens, leftStickX, "Axis0-", "Axis0+");
addAxisTokens(currentTokens, leftStickY, "Axis1-", "Axis1+");
addAxisTokens(currentTokens, rightStickX, "Axis2-", "Axis2+");
addAxisTokens(currentTokens, rightStickY, "Axis3-", "Axis3+");
addPositiveAxisToken(currentTokens, positiveControllerAxisValue(event, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE), "Button6");
addPositiveAxisToken(currentTokens, positiveControllerAxisValue(event, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS), "Button7");
@@ -288,7 +288,7 @@ public class ThorDualScreenPlugin extends Plugin implements DisplayManager.Displ
webView.setWebViewClient(new WebViewClient() {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
return bridge.getLocalServer().shouldInterceptRequest(request);
return bridge.getWebViewClient().shouldInterceptRequest(view, request);
}
});
}
+45
View File
@@ -0,0 +1,45 @@
# Lossless game content packaging
`npm run web:prepare` and `npm run android:prepare` include all 84 regular
dungeon packs, the existing core packs, and the dungeons' dependency closure.
Manastorm packs keep their optional install flag even when a regular dungeon
uses the same map. Manastorm-only maps and extra character packs stay optional.
The staging directories contain one object per original SHA-256 and extension
under `content/bundled`. Gzip is used only when it saves space. The packager
expands each compressed object and compares every byte with its source before
writing the bootstrap catalog. Source GLBs, texture resolutions, animations,
and audio are unchanged. No media is generated by this pipeline.
Compressed objects end in `.gzip`, not `.gz`: Android's asset packager expands
and renames `.gz` inputs. The `.gzip` extension is explicitly stored without
additional ZIP compression so the manifest and APK preserve identical bytes.
Each logical asset keeps its original byte count and SHA-256 in the manifest.
An optional `download` record describes its gzip representation, including a
separate compressed hash and byte count. The schema remains compatible with
existing catalogs. Remote publishing retains original objects for older apps;
new clients prefer the compressed representation and verify both hashes.
The Node server and Vite preview middleware resolve bundled logical URLs on
first launch. The browser service worker does the same after activation.
Android streams bundled gzip through its WebView asset handler, including the
companion display, without writing an expanded copy to app storage. Deploy the
browser build with the project's Node server so first-launch asset aliases work.
Browser optional installs store compressed objects once and keep small logical
aliases. Legacy uncompressed cache entries remain readable. Removing a pack
removes an object only after its last installed reference is gone. Android
optional downloads resume the compressed transfer, verify it, expand and verify
the original, and atomically activate the existing shared native object file.
`content/storage-report.json` inside each staging/build directory records actual
unique object bytes and duplicate savings. These are packaged asset sizes, not
GPU memory estimates. Optional Android downloads occupy their expanded size;
bundled Android assets stay compressed inside the APK.
Validation: `npm run content:test`, the content/avatar Vitest suites, the
bundled-content server tests, `npm run build`, Android compilation, and a live
roster plus dungeon screenshot check. Run `npm run release:check` before handoff.
Android build commands also run `scripts/content/verify-android-package.py`
against the resulting APK, checking every stored object and every original hash.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.28",
"version": "0.1.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.28",
"version": "0.1.29",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.28",
"version": "0.1.29",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -68,6 +68,7 @@
"dungeon:validate": "node scripts/dungeon-pipeline/cli.mjs validate",
"arena:assets:build": "node scripts/arena-pipeline/build-wmo-arenas.mjs && node scripts/arena-pipeline/build-custom-arenas.mjs && node scripts/arena-pipeline/prepare-warnorken.mjs",
"arena:assets:validate": "node scripts/arena-pipeline/validate-arenas.mjs",
"arena:abilities:build": "node scripts/arena-pipeline/build-combat-runtime.mjs && node scripts/arena-pipeline/build-runtime-geometry.mjs",
"dungeon:enable": "node scripts/dungeon-pipeline/cli.mjs enable",
"runewaker:forsaken": "node \"%INIT_CWD%\\scripts\\runewaker-pipeline\\build-forsaken-abbey.mjs\"",
"runewaker:forsaken:actors": "node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only",
@@ -108,7 +109,7 @@
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
"prebuild": "npm run loot:generate && npm run web:prepare && npm run arena:assets:validate && npm run assets:ktx2:audit && npm run assets:performance:audit",
"prebuild": "npm run loot:generate && npm run arena:abilities:build && npm run web:prepare && npm run arena:assets:validate && npm run assets:ktx2:audit && npm run assets:performance:audit",
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
"build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android",
"android:sync": "npm run build:android:web && cap sync android",
@@ -8,9 +8,9 @@
"valid": true
}
],
"gridCells": 2074478,
"gridHeight": 994,
"gridWidth": 2087,
"gridCells": 4046196,
"gridHeight": 1812,
"gridWidth": 2233,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
@@ -21,55 +21,55 @@
"tileRows": 1,
"tileSize": null
},
"dungeonId": "black-rook-hold-arena",
"dungeonId": "arena-runtime-black-rook",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-92.08255767822266,
-16.14961814880371,
-279.4311218261719
-102.83599853515625,
-49.58357238769531,
-373.1059875488281
],
[
321.9174499511719,
37.25038528442383,
-82.83113098144531
340.9640197753906,
410.1164245605469,
-28.105987548828125
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 7170,
"sourceVertices": 21510,
"triangles": 7170,
"vertices": 21510
"sourceTriangles": 26724,
"sourceVertices": 80172,
"triangles": 26724,
"vertices": 80172
},
"input": {
"bounds": [
[
-93.08255767822266,
-39.549617767333984,
-280.0311279296875
-103.83599853515625,
-98.98357391357422,
-375.3059997558594
],
[
324.1624450683594,
69.1881103515625,
-81.26593780517578
342.7250061035156,
410.1130065917969,
-13.022000312805176
]
],
"degenerateTriangleCount": 0,
"degenerateTriangleCount": 33,
"invalidIndexCount": 0,
"meshCount": 1,
"nonFiniteCoordinateCount": 0,
"triangleCount": 49821,
"vertexCount": 149463
"triangleCount": 192419,
"vertexCount": 577257
},
"inputChunkChecksums": {
"public/assets/game/arenas/black-rook-hold-arena/navigation-collision.glb": "285bdce7d5b5085eef7d25f82a5b6a5b380605a310c539e8e94984182c835749"
"playtest-artifacts/black-rook-combined.glb": "aa4f39620e2562cea0bf39ec54ff377b19eef799eaa08f0e9ea81896f8a31aba"
},
"inputChunks": [
"public/assets/game/arenas/black-rook-hold-arena/navigation-collision.glb"
"playtest-artifacts/black-rook-combined.glb"
],
"output": "public/assets/game/arenas/black-rook-hold-arena/navigation-raw.glb",
"output": "playtest-artifacts/black-rook-navigation.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
@@ -80,35 +80,6 @@
"cellSize": 0.2
},
"source": {
"adtFiles": [
"blackrookholdarena_28_28.adt",
"blackrookholdarena_28_29.adt",
"blackrookholdarena_28_30.adt",
"blackrookholdarena_29_28.adt",
"blackrookholdarena_29_29.adt",
"blackrookholdarena_29_30.adt",
"blackrookholdarena_30_28.adt",
"blackrookholdarena_30_29.adt",
"blackrookholdarena_30_30.adt"
],
"gameplayBounds": {
"max": [
312,
30,
-110
],
"min": [
-82,
-10,
-242
]
},
"kind": "adt-placed-wmo-repair",
"legacyCollision": "public/assets/game/manastorm/1784-blackrookholdarena/collision.glb",
"placedWmos": [
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_BLACKROOKHOLD_MICRO01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/7VS_BLACKROOKHOLD_ARENA.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/BLACKROOKHOLD_ARENA_PLATFORMTEST.WMO"
]
"kind": "runtime-collision-repair"
}
}
@@ -56,15 +56,16 @@
"collisionChunks": 4
},
"navigation": {
"triangles": 4948,
"vertices": 14844,
"sourceComponents": 805,
"triangles": 783,
"vertices": 2349,
"sourceComponents": 1078,
"spawnDistances": [
0.009180110009213026,
0.01568599673120942
2.095,
1.026
],
"rawTriangles": 7170,
"report": "recast-report.json"
"rawTriangles": 26724,
"report": "recast-report.json",
"repair": "Rebaked against restored collision. Both teams spawn on the connected interior floor, not the terrain above the arena."
},
"assets": [
{
@@ -94,8 +95,8 @@
},
{
"file": "navigation.glb",
"bytes": 238420,
"sha256": "e463a0220842a4793b2150e95f4fd704f575def288813c781792b021afe8d3f0"
"bytes": 38500,
"sha256": "ee2df9f0c2dbd14d53e84fd66efcc4b48a93ac99c57c27a7d1ea876d29899910"
}
]
}
+55 -1
View File
@@ -1,10 +1,51 @@
const CONTENT_CACHE_NAME = "healer-man-content-v1";
const OBJECT_HEADER = "x-healer-man-content-object";
const ENCODING_HEADER = "x-healer-man-content-encoding";
let bundledManifest;
function mimeType(path) {
const extension = path.split(".").pop();
return ({ glb: "model/gltf-binary", json: "application/json", js: "text/javascript", wasm: "application/wasm",
ktx2: "image/ktx2", png: "image/png", webp: "image/webp", avif: "image/avif", svg: "image/svg+xml",
mp3: "audio/mpeg", ogg: "audio/ogg", wav: "audio/wav", webm: "video/webm" })[extension] ?? "application/octet-stream";
}
async function decodedResponse(response, path, encoding) {
if (encoding !== "gzip") return response;
const headers = new Headers(response.headers);
headers.set("content-type", mimeType(path));
headers.delete("content-length");
headers.delete("content-encoding");
headers.delete(ENCODING_HEADER);
return new Response(response.body.pipeThrough(new DecompressionStream("gzip")), { status: 200, headers });
}
async function bundledAsset(path) {
if (!bundledManifest) {
bundledManifest = fetch("/content/bootstrap-manifest.json", { cache: "no-cache" })
.then((response) => response.ok ? response.json() : null)
.catch(() => null);
}
const manifest = await bundledManifest;
if (!manifest) { bundledManifest = null; return null; }
const entry = manifest.files?.[path];
if (!entry) return null;
const object = entry.download ?? entry;
// Older bootstrap catalogs served their original logical paths directly.
if (!object.url.startsWith("/content/bundled/")) return fetch(path);
const response = await fetch(object.url);
if (!response.ok) return response;
if (entry.download) return decodedResponse(response, path, entry.download.encoding);
return new Response(response.body, { headers: { "content-type": mimeType(path) } });
}
const MANAGED_ASSET_PREFIXES = [
"/assets/characters/",
"/assets/creatures/",
"/assets/equipment/",
"/assets/game/",
"/assets/ui/",
"/basis/",
"/data/",
];
const BUNDLED_ASSET_PREFIXES = [
"/assets/ui/",
@@ -36,6 +77,10 @@ self.addEventListener("install", (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("message", (event) => {
if (event.data?.type === "refresh-content-bootstrap") bundledManifest = null;
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
const names = await caches.keys();
@@ -56,7 +101,16 @@ self.addEventListener("fetch", (event) => {
event.respondWith((async () => {
const cache = await caches.open(CONTENT_CACHE_NAME);
const cached = await cache.match(request, { ignoreSearch: true });
if (cached) return cached;
if (cached) {
const objectPath = cached.headers.get(OBJECT_HEADER);
if (!objectPath) return cached;
if (objectPath.startsWith("/content/cache-objects/")) {
const object = await cache.match(new URL(objectPath, self.location.origin).toString());
if (object) return decodedResponse(object, url.pathname, object.headers.get(ENCODING_HEADER));
}
}
const bundled = await bundledAsset(url.pathname);
if (bundled) return bundled;
if (BUNDLED_ASSET_PATHS.has(url.pathname)
|| BUNDLED_ASSET_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) {
return fetch(request);
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.28",
"versionCode": 1028
"versionName": "0.1.29",
"versionCode": 1029
}
@@ -0,0 +1,38 @@
import { build } from "esbuild";
import { resolve, dirname } from "node:path";
import { readFileSync, readdirSync } from "node:fs";
// A private copy of the existing engine keeps dedicated matches and offline
// practice isolated from the React player's stores. No second spell interpreter.
await build({
entryPoints: ["scripts/arena-pipeline/combat-runtime.ts"],
outfile: "server/generated/arena-combat-runtime.mjs",
bundle: true,
format: "esm",
platform: "neutral",
mainFields: ["module", "main"],
target: "es2022",
// Escape catalog line breaks in string literals, preserving tooltip text
// without generating source lines that end in whitespace.
supported: { "template-literal": false },
minify: true,
define: { "import.meta.env.MODE": '"test"', "import.meta.env.DEV": "false", "import.meta.env.BASE_URL": '"/"', "process.env.NODE_ENV": '"production"' },
plugins: [{
name: "arena-engine-host",
setup(builder) {
builder.onLoad({ filter: /coaAbilityCatalogLoader\.ts$/ }, ({ path }) => {
const paths = readdirSync(resolve(dirname(path), "generated/coaAbilities")).filter((name) => name.endsWith(".json"))
.map((name) => `./generated/coaAbilities/${name}`);
return { contents: readFileSync(path, "utf8").replace(/import\.meta\.glob<[^>]+>\([^;]+\)/, `{${paths.map((name) => `${JSON.stringify(name)}: () => import(${JSON.stringify(name)})`).join(",")}}`), loader: "ts" };
});
builder.onResolve({ filter: /^zustand$/ }, () => ({ path: resolve("scripts/arena-pipeline/runtime-store.ts") }));
builder.onResolve({ filter: /^\.\/store$/ }, ({ importer }) => importer.endsWith("combatStore.ts") ? ({ path: "game-store", namespace: "arena-host" }) : undefined);
builder.onResolve({ filter: /^\.\/dungeonEncounterRuntime$/ }, ({ importer }) => importer.endsWith("combatStore.ts") ? ({ path: "encounter", namespace: "arena-host" }) : undefined);
builder.onResolve({ filter: /^\.\/onlineSessionRuntime$/ }, ({ importer }) => importer.endsWith("combatStore.ts") ? ({ path: "online", namespace: "arena-host" }) : undefined);
builder.onLoad({ filter: /.*/, namespace: "arena-host" }, ({ path }) => ({ contents: path === "game-store"
? 'export const useGameStore = { getState: () => ({ gameMode: "arena" }) };'
: path === "online" ? 'export const onlinePlayerRuntimeActors = () => []; export const onlinePlayerRuntimeActor = () => null;'
: 'export const dispatchDungeonEncounterSignal = () => {}; export const dungeonEncounterStateMatches = () => false;', loader: "js" }));
},
}],
});
@@ -28,9 +28,8 @@ const DEFINITIONS = Object.freeze([
primaryPattern: /7vs_blackrookhold_arena\.wmo$/i,
alignmentOffset: [117.542488, -102.29949, -224.463489],
legacyCollision: "public/assets/game/manastorm/1784-blackrookholdarena/collision.glb",
gameplayBounds: { min: [-82, -10, -242], max: [312, 30, -110] },
legacyNavigation: "public/assets/game/manastorm/1784-blackrookholdarena/navigation.glb",
teamSpawns: [[-66.098564, 8.416428, -201.852448], [123.158585, 9.916428, -219.909592]],
gameplayBounds: { min: [-37, -32, -286], max: [126, 20, -138] },
teamSpawns: [[-35.043, -28.317, -204.435], [108.031, -28.317, -205.573]],
},
{
slug: "ashamanes-fall",
@@ -273,11 +272,8 @@ async function build(definition) {
reportFile: path.join(outputDirectory, "recast-report.json"),
source: { kind: "adt-placed-wmo-repair", adtFiles, placedWmos: sources, legacyCollision: definition.legacyCollision, gameplayBounds: definition.gameplayBounds },
});
const selectionSource = definition.legacyNavigation
? path.join(projectRoot, ...definition.legacyNavigation.split("/"))
: rawNavigationFile;
const connectedNavigation = await extractNavigationComponent({
source: selectionSource,
source: rawNavigationFile,
output: navigationFile,
teamSpawns: definition.teamSpawns,
});
@@ -0,0 +1,38 @@
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { Matrix4, Vector3 } from "three";
import { writeFileSync, readdirSync } from "node:fs";
import catalog from "../../shared/arena-stages.json" with { type: "json" };
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const stages = {};
for (const stage of catalog.stages) {
stages[stage.id] = {};
for (const kind of ["navigation", "collision"]) {
const triangles = [];
const files = kind === "collision" ? readdirSync(`public${stage.assetBaseUrl}`).filter((name) => /^collision(?:-\d+)?\.glb$/.test(name)) : ["navigation.glb"];
for (const file of files) {
const document = await io.read(`public${stage.assetBaseUrl}/${file}`);
for (const node of document.getRoot().listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const matrix = new Matrix4().fromArray(node.getWorldMatrix());
for (const primitive of mesh.listPrimitives()) {
const positions = primitive.getAttribute("POSITION");
if (!positions) continue;
const indices = primitive.getIndices()?.getArray() ?? Array.from({ length: positions.getCount() }, (_, index) => index);
for (let offset = 0; offset < indices.length; offset += 3) {
const triangle = Array.from({ length: 3 }, (_, corner) => new Vector3().fromArray(positions.getElement(indices[offset + corner], [])).applyMatrix4(matrix).toArray());
if ([0, 2].some((axis) => triangle.every((point) => point[axis] < stage.bounds.min[axis] - 3) || triangle.every((point) => point[axis] > stage.bounds.max[axis] + 3))) continue;
triangles.push(...triangle.flat().map((value) => Math.round(value * 1000) / 1000));
}
}
}
}
stages[stage.id][kind] = triangles;
}
console.log(`${stage.id}: ${stages[stage.id].navigation.length / 9} navigation, ${stages[stage.id].collision.length / 9} collision triangles`);
}
writeFileSync("server/generated/arena-geometry.json", `${JSON.stringify(stages)}\n`);
writeFileSync("server/generated/arena-navigation.json", `${JSON.stringify(Object.fromEntries(Object.entries(stages).map(([id, stage]) => [id, stage.navigation])))}\n`);
+281
View File
@@ -0,0 +1,281 @@
import { useCombatStore, PLAYER_AURA_ENTITY_ID, characterCombatDefender, abilityDamageSchool, type CombatState, type CharacterCombatSeed } from "../../src/game/combatStore";
import { usePartyStore } from "../../src/game/partyStore";
import { installCombatClock } from "../../src/game/combatClock";
import { registerCombatSpatialProvider } from "../../src/game/combatSpatial";
import { clearMobRuntimeRegistry, registerMobRuntime, getMobPosition } from "../../src/game/mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, getPartyRuntimePosition } from "../../src/game/partyRuntimeRegistry";
import { targetingRulesForAbility } from "../../src/game/abilityRules";
import { resourceProfileForClass, abilityAtLevel, isAbilityUnlocked } from "../../src/game/abilityCatalog";
import { deriveCharacterStats } from "../../src/game/wotlkStats";
import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
import { arenaDampening } from "../../server/arena-rules.mjs";
import { createDefaultAppearance } from "../../src/app/characterCatalog";
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "../../src/game/equipment";
const PLAYER = PLAYER_AURA_ENTITY_ID;
const combatDefaults = useCombatStore.getState();
const partyDefaults = usePartyStore.getState();
let presentation: any[] = [];
subscribeCombatPresentation((message) => { if (message.kind === "event") presentation.push(message.event); });
const remap = (id: string | null | undefined, from: string, to: string) => id === from ? to : id;
const mapAura = (aura: any, from: string, to: string) => ({ ...aura, sourceId: remap(aura.sourceId, from, to), targetId: remap(aura.targetId, from, to) });
const mapEffect = (effect: any, from: string, to: string) => ({ ...effect, sourceActorId: remap(effect.sourceActorId, from, to), targetId: remap(effect.targetId, from, to) });
export function initializeArenaCharacter(actor: any, now: number) {
if (actor.combat) return;
const kit = actor.characterKit;
const seed: CharacterCombatSeed = kit?.seed ?? { classId: kit?.classId ?? (actor.role === "healer" ? "priest" : "warrior"), level: kit?.level ?? 80, actionBindings: kit?.actionBindings };
const restore = installCombatClock(() => now);
try {
usePartyStore.setState({ ...partyDefaults, members: [], playerRole: actor.role === "healer" ? "healer" : "damage" }, true);
useCombatStore.setState(combatDefaults, true);
useCombatStore.getState().initializeCharacter(seed);
actor.combat = useCombatStore.getState();
const state = actor.combat;
actor.avatar = { classId: state.classId, raceId: seed.raceId ?? (actor.team === 1 ? "orc" : "human"),
gender: (seed as any).gender ?? "male", appearance: (seed as any).appearance ?? createDefaultAppearance() };
actor.visualEquipment = equipmentItemsForOwner(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID);
const abilities = state.abilities.filter((ability: any) => !ability.passive && isAbilityUnlocked(ability, state.level)
&& (ability.talentEntryId === undefined || (state.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0))
.map((ability: any) => { const resolved = abilityAtLevel(ability, state.level, ability.talentEntryId === undefined ? undefined : state.talentRanks[`coa-entry-${ability.talentEntryId}`]); return { ...resolved, targeting: targetingRulesForAbility(resolved) }; });
actor.characterKit = { ...kit, seed, classId: state.classId, level: state.level,
resource: { ...resourceProfileForClass(state.classId), maximum: state.maxResource, initial: state.resource },
abilities: Object.fromEntries(abilities.map((ability: any) => [ability.id, ability])), actionBindings: state.actionBindings };
actor.health = state.health;
actor.maxHealth = state.maxHealth;
actor.resource = state.resource;
actor.maxResource = state.maxResource;
actor.auras = state.auras.map((aura: any) => mapAura(aura, PLAYER, actor.id));
actor.statuses = [];
} finally { restore(); }
}
export function arenaKitFromSeed(seed: CharacterCombatSeed) {
const actor: any = { id: "kit", role: seed.role ?? "damage", characterKit: { seed } };
initializeArenaCharacter(actor, 0);
return { ...actor.characterKit, stats: { ...deriveCharacterStats(actor.combat.classId, actor.combat.level, actor.combat.gearStats, actor.combat.raceId, {}, actor.combat.secondaryClassId), health: actor.maxHealth } };
}
function partyMember(actor: any) {
const state = actor.combat;
return { id: actor.id, name: actor.name, classId: state.classId, raceId: state.raceId, gender: "male", appearance: {},
specialization: "", specializationId: state.specializationId, talentTreeId: "", talentRanks: state.talentRanks,
role: actor.role === "healer" ? "healer" : "damage", level: state.level, health: actor.health, maxHealth: actor.maxHealth,
resourceType: actor.characterKit.resource.type, resourceName: state.resourceName, resource: actor.resource, maxResource: actor.maxResource,
resourcePools: state.resourcePools, lastResourceSpentAt: state.lastResourceSpentAt, cooldowns: state.cooldowns,
globalCooldownEndsAt: state.globalCooldownEndsAt, activeCast: state.activeCast, activeCastTargetId: actor.targetActorId,
schoolLockedUntil: state.schoolLockedUntil, runes: state.deathKnightRunes, gearStats: state.gearStats,
status: actor.alive ? "ready" : "down", nextActionAt: 0, tauntReadyAt: 0,
controlledUntil: state.controlledUntil, controlMechanic: state.controlMechanic };
}
function enter(simulation: any, actor: any, now: number, ticking: boolean) {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
presentation = [];
const allies = [...simulation.actors.values()].filter((other: any) => other !== actor && other.team === actor.team);
const friendlyPets = allies.flatMap((owner: any) => owner.combat.summons.map((summon: any) => ({ owner, summon })));
const members = [...allies.map(partyMember), ...friendlyPets.map(({ owner, summon }: any) => ({ ...partyMember(owner),
id: summon.id, name: summon.name, health: summon.health, maxHealth: summon.maxHealth,
resource: 0, maxResource: 0, activeCast: null, status: summon.health > 0 ? "ready" : "down" }))];
usePartyStore.setState({ ...partyDefaults, members, playerRole: actor.role === "healer" ? "healer" : "damage",
selectedMemberId: allies.some((other: any) => other.id === actor.targetActorId) ? actor.targetActorId : null,
selfSelected: actor.targetActorId === actor.id }, true);
const effects = simulation.combatEffects ?? [];
const auras = [...simulation.actors.values()].flatMap((other: any) => other.auras).concat(simulation.summonAuras ?? []).map((aura: any) => mapAura(aura, actor.id, PLAYER));
useCombatStore.setState({ ...actor.combat, health: actor.health, maxHealth: actor.maxHealth,
resource: actor.resource, maxResource: actor.maxResource, playerPosition: [...actor.position],
selectedTargetId: simulation.targetActor(actor.targetActorId)?.team !== actor.team ? actor.targetActorId : null,
selectedFriendlyActorId: actor.targetActorId === actor.id ? PLAYER : simulation.targetActor(actor.targetActorId)?.team === actor.team ? actor.targetActorId : null,
auras, effects: effects.filter((effect: any) => !ticking || effect.sourceActorId === actor.id).map((effect: any) => mapEffect(effect, actor.id, PLAYER)),
mobs: {}, actors: {}, combatEvents: [], settings: { ...actor.combat.settings, autoTargetNearest: false } }, true);
for (const other of simulation.actors.values()) {
if (other === actor) continue;
if (other.team === actor.team) { registerPartyRuntimePosition(other.id, other.position); continue; }
registerMobRuntime(other.id, other.position, other.facing);
useCombatStore.getState().registerMob(other.id, { name: other.name, level: other.combat.level, maxHealth: other.maxHealth, xpReward: 0 });
const stats = deriveCharacterStats(other.combat.classId, other.combat.level, other.combat.gearStats, other.combat.raceId, {}, other.combat.secondaryClassId);
useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [other.id]: { ...state.mobs[other.id],
health: other.health, maxHealth: other.maxHealth, dead: !other.alive, armor: stats.armor, maxMana: other.maxResource,
characterDefender: characterCombatDefender({ ...other.combat, auras: other.auras.map((aura: any) => mapAura(aura, other.id, PLAYER)) }),
characterShield: other.combat.shield, characterResources: other.combat.resourcePools,
statuses: other.statuses ?? [], controlDiminishingReturns: other.controlDiminishingReturns ?? {},
mechanicImmunities: other.auras.flatMap((aura: any) => aura.definition.controlImmunities ?? []),
activeCast: other.combat.activeCast ? { ...other.combat.activeCast, attackId: other.combat.activeCast.abilityId, interruptible: true } : null,
schoolLockedUntil: other.combat.schoolLockedUntil, engaged: true, experienceGranted: true,
resistances: { arcane: stats.arcaneResistance, fire: stats.fireResistance, frost: stats.frostResistance, nature: stats.natureResistance, shadow: stats.shadowResistance, holy: 0 },
} } }));
}
for (const owner of simulation.actors.values()) for (const summon of owner.combat.summons) {
if (owner.team === actor.team) {
if (owner !== actor) registerPartyRuntimePosition(summon.id, summon.position);
continue;
}
registerMobRuntime(summon.id, summon.position, owner.facing);
useCombatStore.getState().registerMob(summon.id, { name: summon.name, level: owner.combat.level, maxHealth: summon.maxHealth, xpReward: 0 });
useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [summon.id]: { ...state.mobs[summon.id], health: summon.health, dead: summon.health <= 0, experienceGranted: true } } }));
}
useCombatStore.getState().synchronizeActors();
const restoreClock = installCombatClock(() => now);
const restoreSpatial = registerCombatSpatialProvider({
actor: (id) => { const target = id === PLAYER ? actor : simulation.targetActor(id); return target ? { id, position: target.position, yaw: target.facing } : null; },
hasLineOfSight: (from, to) => simulation.hasLineOfSight({ position: from }, { position: to }),
projectGroundPoint: (requested, range, origin = actor.position) => simulation.projectGroundPoint(origin, requested, range),
});
return () => { restoreSpatial(); restoreClock(); };
}
function commit(simulation: any, actor: any, now: number, ticking: boolean) {
const state = useCombatStore.getState();
const dampening = simulation.activeStartedAt == null ? 0 : arenaDampening(now - simulation.activeStartedAt);
const healed = new Map<string, number>();
const damaged = new Map<string, number>();
const previous = new Map<string, { health: number; maxHealth: number }>([...simulation.actors.values()].map((other: any) => [other.id, { health: other.health, maxHealth: other.maxHealth }]));
for (const event of state.combatEvents) if (event.kind === "healing") {
const id = remap(event.targetActorId, PLAYER, actor.id)!;
healed.set(id, (healed.get(id) ?? 0) + event.effectiveAmount);
}
for (const event of state.combatEvents) if (event.kind === "damage") {
const id = remap(event.targetActorId, PLAYER, actor.id)!;
damaged.set(id, (damaged.get(id) ?? 0) + event.effectiveAmount);
}
const healthAfterHealing = (id: string, health: number, maxHealth = previous.get(id)?.maxHealth ?? health) => {
const before = previous.get(id);
const inferred = before ? Math.max(0, health - before.health + (damaged.get(id) ?? 0) - Math.max(0, maxHealth - before.maxHealth)) : 0;
return Math.max(0, health - Math.round(Math.max(healed.get(id) ?? 0, inferred) * dampening));
};
actor.combat = { ...state, mobs: {}, actors: {}, effects: [], auras: [], health: actor.health <= 0 ? 0 : healthAfterHealing(actor.id, state.health, state.maxHealth) };
actor.health = actor.combat.health;
actor.maxHealth = state.maxHealth;
actor.resource = state.resource;
actor.maxResource = state.maxResource;
actor.position = state.playerPosition.some((value, axis) => value !== actor.position[axis])
? simulation.resolvePosition(actor, state.playerPosition, true) : [...actor.position];
actor.cooldowns = new Map(Object.entries(state.cooldowns));
actor.gcdUntil = state.globalCooldownEndsAt;
actor.casting = state.activeCast ? { ...state.activeCast, targetActorId: remap(state.activeCast.targetId, PLAYER, actor.id) } : null;
actor.statuses = (actor.statuses ?? []).filter((status: any) => status.endsAt > now && !(state.controlledUntil === 0 && ["stun", "fear", "sleep", "freeze", "knockdown"].includes(status.kind)));
for (const member of usePartyStore.getState().members) {
const other = simulation.actor(member.id);
if (!other) {
for (const owner of simulation.actors.values()) if (owner !== actor && owner.combat.summons.some((summon: any) => summon.id === member.id)) {
owner.combat = { ...owner.combat, summons: owner.combat.summons.map((summon: any) => summon.id === member.id ? { ...summon, health: healthAfterHealing(member.id, member.health) } : summon) };
}
continue;
}
other.health = healthAfterHealing(other.id, member.health, member.maxHealth);
other.maxHealth = member.maxHealth;
other.resource = member.resource;
const moved = getPartyRuntimePosition(member.id);
if (moved && moved.some((value, axis) => value !== other.position[axis])) other.position = simulation.resolvePosition(other, moved, true);
other.combat = { ...other.combat, resourcePools: member.resourcePools, schoolLockedUntil: member.schoolLockedUntil,
controlledUntil: member.controlledUntil, controlMechanic: member.controlMechanic, deathKnightRunes: member.runes };
}
for (const mob of Object.values(state.mobs)) {
const other = simulation.actor(mob.id);
if (!other) {
for (const owner of simulation.actors.values()) if (owner !== actor && owner.combat.summons.some((summon: any) => summon.id === mob.id)) {
owner.combat = { ...owner.combat, summons: owner.combat.summons.map((summon: any) => summon.id === mob.id ? { ...summon, health: mob.health } : summon).filter((summon: any) => summon.health > 0) };
}
continue;
}
other.health = mob.health;
other.statuses = mob.statuses;
other.controlDiminishingReturns = mob.controlDiminishingReturns;
const moved = getMobPosition(mob.id);
if (moved && moved.some((value, axis) => value !== other.position[axis])) other.position = simulation.resolvePosition(other, moved, true);
const control = mob.statuses.filter((status) => ["stun", "fear", "confuse", "blind", "charm", "freeze", "knockdown", "sleep"].includes(status.kind)).sort((a, b) => b.endsAt - a.endsAt)[0];
other.combat = { ...other.combat, controlledUntil: control?.endsAt ?? 0, controlMechanic: control?.kind ?? null,
shield: mob.characterShield ?? other.combat.shield, resourcePools: mob.characterResources ?? other.combat.resourcePools,
schoolLockedUntil: mob.schoolLockedUntil, activeCast: mob.activeCast ? { ...other.combat.activeCast, ...mob.activeCast } : null };
other.resource = other.combat.resourcePools[other.characterKit.resource.type] ?? other.resource;
other.casting = other.combat.activeCast;
}
const worldAuras = state.auras.map((aura) => mapAura(aura, PLAYER, actor.id));
simulation.summonAuras = worldAuras.filter((aura) => !simulation.actors.has(aura.targetId));
for (const other of simulation.actors.values()) {
other.auras = worldAuras.filter((aura) => aura.targetId === other.id);
other.statuses = (other.statuses ?? []).filter((status: any) => status.endsAt > now
&& (!status.sourceAbilityId.startsWith("aura:") || other.auras.some((aura: any) => `aura:${aura.definition.id}` === status.sourceAbilityId))
&& !other.auras.some((aura: any) => aura.definition.controlImmunities?.includes(status.kind)));
const control = other.statuses.filter((status: any) => ["stun", "fear", "confuse", "blind", "charm", "freeze", "knockdown", "sleep"].includes(status.kind)).sort((a: any, b: any) => b.endsAt - a.endsAt)[0];
other.combat = { ...other.combat, controlledUntil: control?.endsAt ?? 0, controlMechanic: control?.kind ?? null };
other.alive = other.health > 0;
if (!other.alive) { other.movement = [0, 0]; other.casting = null; other.combat = { ...other.combat, activeCast: null }; }
}
simulation.combatEffects = [
...(ticking ? (simulation.combatEffects ?? []).filter((effect: any) => effect.sourceActorId !== actor.id) : []),
...state.effects.map((effect) => mapEffect(effect, PLAYER, actor.id)),
];
simulation.combatPresentation = [...(simulation.combatPresentation ?? []), ...presentation.map((event) => ({ ...event,
sourceActorId: remap(event.sourceActorId, PLAYER, actor.id), targetActorId: remap(event.targetActorId, PLAYER, actor.id) }))].slice(-80);
simulation.combatEvents = [...(simulation.combatEvents ?? []), ...state.combatEvents.map((event) => ({ ...event,
sourceActorId: remap(event.sourceActorId, PLAYER, actor.id), targetActorId: remap(event.targetActorId, PLAYER, actor.id) }))].slice(-100);
}
export function useArenaCharacterAbility(simulation: any, actor: any, abilityId: string, targetActorId: string | null, now: number, groundPosition?: readonly number[]) {
const ability = actor.characterKit.abilities[abilityId];
if (ability && (actor.statuses ?? []).some((status: any) => status.kind === "silence" && status.endsAt > now)
&& (ability.castRules?.school ?? abilityDamageSchool(actor.combat.classId, ability)) !== "physical") return { accepted: false, reason: "controlled", message: "You are silenced." };
const restore = enter(simulation, actor, now, false);
try {
const requestedTarget = targetActorId ?? actor.targetActorId;
if (requestedTarget === actor.id) {
useCombatStore.setState({ selectedFriendlyActorId: PLAYER, selectedTargetId: null });
usePartyStore.setState({ selfSelected: true, selectedMemberId: null });
}
const result = useCombatStore.getState().castAbility(abilityId, { now, origin: actor.position,
targetActorId: requestedTarget === actor.id ? null : requestedTarget, groundPosition: groundPosition as any });
const message = useCombatStore.getState().feedback?.message;
commit(simulation, actor, now, false);
if (result.ok) { actor.lastAbilityId = abilityId; actor.abilityRevision += 1; }
return { accepted: result.ok, reason: result.reason, message, casting: Boolean(actor.casting) };
} finally { restore(); }
}
export function tickArenaCharacter(simulation: any, actor: any, now: number, delta: number) {
const restore = enter(simulation, actor, now, true);
try {
if (actor.casting && !actor.casting.canCastWhileMoving && Math.hypot(...actor.movement) > .01) useCombatStore.getState().cancelCast("movement");
useCombatStore.getState().tick(delta, now);
commit(simulation, actor, now, true);
} finally { restore(); }
}
export function arenaCharacterSnapshot(actor: any, now: number) {
const state: CombatState = actor.combat;
return { resourcePools: state.resourcePools, maxResourcePools: state.maxResourcePools, runes: state.deathKnightRunes,
talentRanks: state.talentRanks, gearStats: state.gearStats, specializationId: state.specializationId,
comboPoints: state.comboPoints, comboTargetId: state.comboTargetId, shield: state.shield,
activeCast: state.activeCast, playerAnimationEvent: state.playerAnimationEvent, summons: state.summons.map((summon) => ({ ...summon, ownerActorId: actor.id })),
areaEffects: state.areaEffects.map((area) => ({ ...area, sourceActorId: actor.id })),
controlledUntil: state.controlledUntil, schoolLockedUntil: state.schoolLockedUntil, now };
}
export function damageArenaCharacter(simulation: any, actor: any, amount: number, school: any, now: number) {
const restore = enter(simulation, actor, now, false);
try {
useCombatStore.getState().damagePlayer(amount, school);
commit(simulation, actor, now, false);
} finally { restore(); }
}
export function commandArenaCharacter(simulation: any, actor: any, command: string, now: number) {
if (command === "cancel-cast") {
const restore = enter(simulation, actor, now, false);
try { useCombatStore.getState().cancelCast(); commit(simulation, actor, now, false); } finally { restore(); }
}
if (command === "attack" || command === "defend" || command === "stop" || command === "recall") {
const targetId = command === "attack" ? actor.targetActorId : null;
actor.combat = { ...actor.combat, autoAttackTargetId: targetId, summons: actor.combat.summons.map((summon: any) => ({ ...summon,
targetId, ...(command === "recall" ? { position: [...actor.position] } : {}), nextActionAt: command === "stop" ? Number.MAX_SAFE_INTEGER : now })) };
}
}
export function bindArenaCharacter(actor: any, bindings: Record<string, string | null>) {
const next = Object.fromEntries(Object.keys(actor.characterKit.actionBindings).map((id) => [id,
bindings[id] && actor.characterKit.abilities[bindings[id]!] ? bindings[id] : null]));
actor.characterKit = { ...actor.characterKit, actionBindings: next };
actor.combat = { ...actor.combat, actionBindings: next };
}
+18
View File
@@ -0,0 +1,18 @@
// Same vanilla store contract used by Zustand, without a React dependency on
// the dedicated server. Each arena operation restores its own saved state.
export function create<T>(initialize: (set: any, get: () => T, api: any) => T) {
let state: T;
const listeners = new Set<(state: T, previous: T) => void>();
const api = {
getState: () => state,
setState(update: any, replace = false) {
const previous = state;
const patch = typeof update === "function" ? update(state) : update;
state = replace ? patch : Object.assign({}, state, patch);
if (state !== previous) for (const listener of listeners) listener(state, previous);
},
subscribe(listener: (state: T, previous: T) => void) { listeners.add(listener); return () => listeners.delete(listener); },
};
state = initialize(api.setState, api.getState, api);
return api;
}
+62
View File
@@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { gzip, gunzip } from "node:zlib";
const compress = promisify(gzip);
const decompress = promisify(gunzip);
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
/** Original hashes remain the runtime contract; gzip is only a storage representation. */
export async function prepareContentObjects(files, { directory, baseUrl, keepOriginal = false }) {
await mkdir(directory, { recursive: true });
const unique = new Map();
for (const file of files) unique.set(`${file.sha256}${file.extension}`, file);
const objects = [...unique.entries()];
const prepared = new Map();
let next = 0;
let writtenObjects = 0;
const writeVerified = async (name, bytes) => {
const destination = path.join(directory, name);
try {
const existing = await readFile(destination);
if (!existing.equals(bytes)) throw new Error(`Published object differs from its hash: ${destination}`);
} catch (error) {
if (error.code !== "ENOENT") throw error;
await writeFile(destination, bytes, { flag: "wx" });
writtenObjects++;
}
};
await Promise.all(Array.from({ length: 4 }, async () => {
while (next < objects.length) {
const [name, file] = objects[next++];
const original = await readFile(file.sourcePath);
if (original.length !== file.bytes || digest(original) !== file.sha256) {
throw new Error(`Asset changed during packaging: ${file.logicalPath}`);
}
const compressed = await compress(original, { level: 6 });
const useful = compressed.length + 64 < original.length;
let download;
if (useful) {
if (!(await decompress(compressed)).equals(original)) throw new Error(`Gzip round trip failed: ${file.logicalPath}`);
const compressedHash = digest(compressed);
// AAPT expands and renames .gz assets even with noCompress. Keep gzip
// bytes under a neutral suffix so APK and browser catalogs stay identical.
const compressedName = `${compressedHash}${file.extension}.gzip`;
await writeVerified(compressedName, compressed);
download = { url: `${baseUrl}/${compressedName}`, encoding: "gzip", sha256: compressedHash, bytes: compressed.length };
}
if (keepOriginal || !useful) await writeVerified(name, original);
prepared.set(name, { objectUrl: `${baseUrl}/${name}`, ...(download ? { download } : {}), storedBytes: useful ? compressed.length : original.length });
}
}));
return {
files: files.map((file) => ({ ...file, ...prepared.get(`${file.sha256}${file.extension}`) })),
writtenObjects,
uniqueObjects: objects.length,
logicalBytes: files.reduce((sum, file) => sum + file.bytes, 0),
uniqueBytes: objects.reduce((sum, [, file]) => sum + file.bytes, 0),
storedBytes: [...prepared.values()].reduce((sum, object) => sum + object.storedBytes, 0),
};
}
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { gunzipSync } from "node:zlib";
import { prepareContentObjects } from "./content-objects.mjs";
import { selectBootstrapContent } from "./prepare-bootstrap.mjs";
import { createContentManifest } from "./publish-content.mjs";
test("ships one lossless object for identical assets and verifies original bytes", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "healer-content-compression-"));
try {
const sourcePath = path.join(directory, "original.glb");
const original = Buffer.from("original geometry, textures, and animation\n".repeat(4096));
await writeFile(sourcePath, original);
const common = { sourcePath, bytes: original.length, sha256: createHash("sha256").update(original).digest("hex"), extension: ".glb" };
const files = ["first", "second"].map((id) => ({ ...common, logicalPath: `assets/creatures/${id}/model.glb`, packId: `dungeon:${id}` }));
const result = await prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" });
assert.equal(result.uniqueObjects, 1);
assert.equal(result.uniqueBytes, original.length);
assert.equal(result.logicalBytes, original.length * 2);
assert.ok(result.storedBytes < original.length / 10);
assert.equal((await readdir(path.join(directory, "objects"))).length, 1);
assert.deepEqual(result.files[0].download, result.files[1].download);
assert.ok(result.files[0].download.url.endsWith(".gzip"), "avoid Android's automatic .gz expansion");
const stored = await readFile(path.join(directory, "objects", path.basename(result.files[0].download.url)));
assert.deepEqual(gunzipSync(stored), original);
assert.deepEqual(await readFile(sourcePath), original);
const again = await prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" });
assert.equal(again.writtenObjects, 0);
assert.equal(createContentManifest(result.files).version, createContentManifest(again.files).version);
await writeFile(sourcePath, "changed");
await assert.rejects(prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" }), /changed during packaging/);
} finally { await rm(directory, { recursive: true, force: true }); }
});
test("bundles regular dungeons and exact map dependencies without installing Manastorm", () => {
const ids = ["core:ui", "dungeon:first", "dungeon:second", "shared:creatures", "manastorm:shared-map", "manastorm:optional-map", "characters:extra"];
const files = ids.map((packId, index) => ({ logicalPath: `assets/${index}.glb`, packId, sha256: String(index).repeat(64), bytes: 100, extension: ".glb" }));
const result = selectBootstrapContent(files, [
{ id: "dungeon:first", dependencies: ["manastorm:shared-map", "shared:creatures"] },
{ id: "dungeon:second", dependencies: [] },
]);
assert.deepEqual(result.files.map((file) => file.packId), ids.slice(0, 5));
const packs = new Map(result.packDefinitions.map((pack) => [pack.id, pack]));
assert.equal(packs.get("dungeon:first").bundledOnAndroid, true);
assert.equal(packs.get("dungeon:second").bundledOnAndroid, true);
assert.equal(packs.get("manastorm:shared-map").bundledOnAndroid, false);
assert.equal(packs.has("manastorm:optional-map"), false);
});
+2 -68
View File
@@ -1,69 +1,3 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
BASIC_HUMAN_TEXTURE_PATHS,
PROJECT_ROOT,
createContentManifest,
scanContentSources,
} from "./publish-content.mjs";
import { prepareBootstrap } from "./prepare-bootstrap.mjs";
const outputDirectory = path.join(PROJECT_ROOT, ".android-public");
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".android-public") {
throw new Error(`Refusing to replace unexpected Android staging directory: ${outputDirectory}`);
}
const sources = [
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
logicalPrefix: "assets/characters/wow335a-v1/human",
},
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
logicalPrefix: logicalPath,
})),
{
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
logicalPrefix: "assets/game/arenas",
},
{
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
logicalPrefix: "assets/game/dungeons/wailing-caverns",
},
];
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
const files = await scanContentSources(sources);
for (const file of files) {
const destination = path.join(outputDirectory, ...file.logicalPath.split("/"));
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(file.sourcePath, destination);
}
const generated = createContentManifest(files, { baseUrl: "/" });
const bootstrapManifest = {
...generated,
files: Object.fromEntries(Object.entries(generated.files).map(([logicalPath, entry]) => [
logicalPath,
{ ...entry, url: logicalPath },
])),
};
const manifestPath = path.join(outputDirectory, "content", "bootstrap-manifest.json");
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(manifestPath, `${JSON.stringify(bootstrapManifest, null, 2)}\n`, "utf8");
const bytes = files.reduce((total, file) => total + file.bytes, 0);
console.log(`Prepared Android bootstrap content: ${files.length} files (${bytes} bytes)`);
console.log(`Bootstrap version: ${bootstrapManifest.version}`);
await prepareBootstrap("android");
+63
View File
@@ -0,0 +1,63 @@
import { copyFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
BUNDLED_ANDROID_PACKS, DEFAULT_CONTENT_SOURCES, PROJECT_ROOT,
createContentManifest, loadCampaignDungeonPackDefinitions, scanContentSources,
} from "./publish-content.mjs";
import { prepareContentObjects } from "./content-objects.mjs";
export function selectBootstrapContent(files, packDefinitions) {
const manifest = createContentManifest(files, { packDefinitions });
const packs = new Map(manifest.packs.map((pack) => [pack.id, pack]));
const included = new Set();
function include(id) {
if (included.has(id)) return;
included.add(id);
for (const dependency of packs.get(id)?.dependencies ?? []) include(dependency);
}
for (const pack of manifest.packs) {
if (BUNDLED_ANDROID_PACKS.includes(pack.id) || pack.id.startsWith("dungeon:")) include(pack.id);
}
return {
files: files.filter((file) => included.has(file.packId)),
packDefinitions: manifest.packs.filter((pack) => included.has(pack.id)).map((pack) => ({
...pack,
// Shared maps can be physically bundled without installing Manastorm gameplay.
bundledOnAndroid: !pack.id.startsWith("manastorm:"),
})),
};
}
export async function prepareBootstrap(platform) {
if (!["web", "android"].includes(platform)) throw new Error(`Unknown bootstrap platform: ${platform}`);
const outputDirectory = path.resolve(PROJECT_ROOT, `.${platform}-public`);
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== `.${platform}-public`) {
throw new Error(`Refusing to replace unexpected staging directory: ${outputDirectory}`);
}
const files = await scanContentSources([
...DEFAULT_CONTENT_SOURCES,
{ directory: path.join(PROJECT_ROOT, "public/basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public/data"), logicalPrefix: "data" },
]);
const selected = selectBootstrapContent(files, await loadCampaignDungeonPackDefinitions());
await rm(outputDirectory, { recursive: true, force: true });
const prepared = await prepareContentObjects(selected.files, {
directory: path.join(outputDirectory, "content/bundled"), baseUrl: "/content/bundled",
});
const manifest = createContentManifest(prepared.files, {
baseUrl: "/content/bundled", packDefinitions: selected.packDefinitions,
});
await writeFile(path.join(outputDirectory, "content/bootstrap-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
await copyFile(path.join(PROJECT_ROOT, "public/content-cache-sw.js"), path.join(outputDirectory, "content-cache-sw.js"));
const storage = {
logicalBytes: prepared.logicalBytes, uniqueBytes: prepared.uniqueBytes, storedBytes: prepared.storedBytes,
duplicateBytesRemoved: prepared.logicalBytes - prepared.uniqueBytes,
uniqueObjects: prepared.uniqueObjects, logicalFiles: selected.files.length,
bundledDungeons: manifest.packs.filter((pack) => pack.id.startsWith("dungeon:")).length,
bundledManastormPacks: manifest.packs.filter((pack) => pack.id.startsWith("manastorm:") && pack.bundledOnAndroid).length,
};
await writeFile(path.join(outputDirectory, "content/storage-report.json"), `${JSON.stringify(storage, null, 2)}\n`);
console.log(`Prepared ${platform} bootstrap: ${storage.bundledDungeons} dungeons, ${prepared.uniqueObjects} shared objects`);
console.log(`Original: ${storage.logicalBytes} bytes; duplicates removed: ${storage.duplicateBytesRemoved} bytes; shipped: ${storage.storedBytes} bytes`);
return { manifest, storage, outputDirectory };
}
+2 -74
View File
@@ -1,75 +1,3 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
BASIC_HUMAN_TEXTURE_PATHS,
PROJECT_ROOT,
createContentManifest,
scanContentSources,
} from "./publish-content.mjs";
import { prepareBootstrap } from "./prepare-bootstrap.mjs";
const outputDirectory = path.join(PROJECT_ROOT, ".web-public");
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".web-public") {
throw new Error(`Refusing to replace unexpected browser staging directory: ${outputDirectory}`);
}
const sources = [
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public", "data"), logicalPrefix: "data" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
logicalPrefix: "assets/characters/wow335a-v1/human",
},
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
logicalPrefix: logicalPath,
})),
{
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
logicalPrefix: "assets/game/arenas",
},
{
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
logicalPrefix: "assets/game/dungeons/wailing-caverns",
},
];
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
const files = await scanContentSources(sources);
for (const file of files) {
const destination = path.join(outputDirectory, ...file.logicalPath.split("/"));
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(file.sourcePath, destination);
}
await copyFile(
path.join(PROJECT_ROOT, "public", "content-cache-sw.js"),
path.join(outputDirectory, "content-cache-sw.js"),
);
const generated = createContentManifest(files, { baseUrl: "/" });
const bootstrapManifest = {
...generated,
files: Object.fromEntries(Object.entries(generated.files).map(([logicalPath, entry]) => [
logicalPath,
{ ...entry, url: logicalPath },
])),
};
const manifestPath = path.join(outputDirectory, "content", "bootstrap-manifest.json");
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(manifestPath, `${JSON.stringify(bootstrapManifest, null, 2)}\n`, "utf8");
const bytes = files.reduce((total, file) => total + file.bytes, 0);
console.log(`Prepared browser bootstrap content: ${files.length} files (${bytes} bytes)`);
console.log(`Bootstrap version: ${bootstrapManifest.version}`);
await prepareBootstrap("web");
+17 -28
View File
@@ -1,7 +1,5 @@
import { createHash } from "node:crypto";
import {
constants,
copyFile,
mkdir,
readFile,
readdir,
@@ -11,6 +9,7 @@ import {
} from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { prepareContentObjects } from "./content-objects.mjs";
const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../..");
@@ -190,7 +189,7 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
function contentVersion(files, packs) {
const digest = createHash("sha256");
for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\n`);
for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\0${JSON.stringify(file.download ?? null)}\n`);
for (const pack of packs) {
digest.update(`pack\0${pack.id}\0${pack.label}\0${pack.bundledOnAndroid}\0${pack.dependencies.join(",")}\n`);
}
@@ -305,6 +304,7 @@ export function createContentManifest(files, options = {}) {
url: `${baseUrl}/${encodeUrlPath(objectName)}`,
sha256: file.sha256,
bytes: file.bytes,
...(file.download ? { download: file.download } : {}),
};
const pack = packs.get(file.packId) ?? {
id: file.packId,
@@ -351,6 +351,11 @@ export function createContentManifest(files, options = {}) {
}
const sortedPacks = [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
for (const pack of sortedPacks) {
const objects = new Map(files.filter((file) => file.packId === pack.id)
.map((file) => [file.sha256, file.download?.bytes ?? file.bytes]));
pack.downloadBytes = [...objects.values()].reduce((sum, bytes) => sum + bytes, 0);
}
const version = options.version ?? contentVersion(files, sortedPacks);
return {
@@ -420,24 +425,6 @@ export function assertCampaignDungeonManifest(manifest, definitions) {
}
}
async function copyObjectIfMissing(file, objectDirectory) {
const objectName = `${file.sha256}${file.extension}`;
const destination = path.join(objectDirectory, objectName);
try {
const destinationStats = await stat(destination);
if (destinationStats.size !== file.bytes) throw new Error(`Published object has the wrong size: ${destination}`);
return false;
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
try {
await copyFile(file.sourcePath, destination, constants.COPYFILE_EXCL);
} catch (error) {
if (error?.code !== "EEXIST") throw error;
}
return true;
}
async function replaceJsonAtomically(destination, value) {
const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`;
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
@@ -456,22 +443,24 @@ export async function publishContent(options = {}) {
...campaignPackDefinitions,
...(options.packDefinitions ?? []),
];
const manifest = createContentManifest(files, { ...options, packDefinitions });
if (campaignDefinitions) assertCampaignDungeonManifest(manifest, campaignDefinitions);
if (campaignDefinitions) assertCampaignDungeonManifest(createContentManifest(files, { ...options, packDefinitions }), campaignDefinitions);
const objectDirectory = path.join(contentDirectory, "objects");
const manifestDirectory = path.join(contentDirectory, "manifests");
await mkdir(objectDirectory, { recursive: true });
await mkdir(manifestDirectory, { recursive: true });
let copiedObjects = 0;
for (const file of files) {
if (await copyObjectIfMissing(file, objectDirectory)) copiedObjects += 1;
}
const prepared = await prepareContentObjects(files, {
directory: objectDirectory,
baseUrl: String(options.baseUrl ?? "/content/objects").replace(/\/+$/, ""),
keepOriginal: true,
});
const manifest = createContentManifest(prepared.files, { ...options, packDefinitions });
await replaceJsonAtomically(path.join(manifestDirectory, `${manifest.version}.json`), manifest);
await replaceJsonAtomically(path.join(contentDirectory, "manifest.json"), manifest);
return {
contentDirectory,
copiedObjects,
copiedObjects: prepared.writtenObjects,
storage: { logicalBytes: prepared.logicalBytes, uniqueBytes: prepared.uniqueBytes, storedBytes: prepared.storedBytes },
manifest,
campaignDungeonPackCount: campaignDefinitions?.length ?? 0,
};
+58
View File
@@ -0,0 +1,58 @@
"""Verify the packaged bytes, including Android asset-name/compression behavior."""
import argparse
import gzip
import hashlib
import json
from pathlib import Path
from zipfile import ZIP_STORED, ZipFile
def verify_package(apk: Path) -> dict:
with ZipFile(apk) as archive:
manifest = json.loads(archive.read("assets/public/content/bootstrap-manifest.json"))
checked = set()
object_bytes = 0
for logical_path, entry in manifest["files"].items():
stored = entry.get("download", entry)
if stored["url"] in checked:
continue
checked.add(stored["url"])
name = "assets/public" + stored["url"]
data = archive.read(name)
if len(data) != stored["bytes"] or hashlib.sha256(data).hexdigest() != stored["sha256"]:
raise ValueError(f"Packaged object failed verification: {logical_path}")
object_bytes += len(data)
if "download" in entry:
if archive.getinfo(name).compress_type != ZIP_STORED:
raise ValueError(f"Android recompressed an already compressed object: {name}")
data = gzip.decompress(data)
if len(data) != entry["bytes"] or hashlib.sha256(data).hexdigest() != entry["sha256"]:
raise ValueError(f"Expanded object failed verification: {logical_path}")
dungeons = sum(pack["id"].startswith("dungeon:") and pack["bundledOnAndroid"] for pack in manifest["packs"])
expected_objects = {"assets/public" + url for url in checked}
actual_objects = {name for name in archive.namelist() if name.startswith("assets/public/content/bundled/") and not name.endswith("/")}
if actual_objects != expected_objects:
raise ValueError("APK includes missing or unreferenced bundled objects.")
manastorms = sum(pack["id"].startswith("manastorm:") and pack["bundledOnAndroid"] for pack in manifest["packs"])
if dungeons != 84 or manastorms != 0:
raise ValueError(f"Unexpected bundled content: {dungeons} dungeons, {manastorms} Manastorm packs")
return {
"apk": str(apk.resolve()), "apkBytes": apk.stat().st_size,
"manifestVersion": manifest["version"], "logicalFiles": len(manifest["files"]),
"verifiedUniqueObjects": len(checked), "storedObjectBytes": object_bytes,
"bundledDungeons": dungeons, "bundledManastormPacks": manastorms,
"allOriginalHashesMatch": True,
}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("apk", type=Path)
parser.add_argument("--report", type=Path)
args = parser.parse_args()
result = json.dumps(verify_package(args.apk), indent=2) + "\n"
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(result, encoding="utf-8")
print(result, end="")
+7
View File
@@ -56,6 +56,13 @@ try {
if ($LASTEXITCODE -ne 0) {
throw "Gradle $Task failed with exit code $LASTEXITCODE"
}
$packageType = if ($Task -eq 'assembleRelease') { 'release' } else { 'debug' }
$packageDirectory = Join-Path $projectRoot "android\app\build\outputs\apk\$packageType"
$builtPackage = Get-ChildItem -LiteralPath $packageDirectory -Filter '*.apk' |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $builtPackage) { throw "No APK was produced in $packageDirectory" }
& python (Join-Path $projectRoot 'scripts\content\verify-android-package.py') $builtPackage.FullName
if ($LASTEXITCODE -ne 0) { throw "Packaged content verification failed." }
} finally {
Pop-Location
}
+9
View File
@@ -0,0 +1,9 @@
import { arenaKitFromSeed } from "./generated/arena-combat-runtime.mjs";
/** Server-owned character/loadout data enters the shared character initializer. */
export function arenaKitForLoadout(loadout, character = {}) {
return arenaKitFromSeed({ ...character, classId: loadout.classId, specializationId: loadout.specializationId,
role: loadout.role, level: 80, talentRanks: loadout.talents ?? character.talentRanks,
actionBindings: Object.keys(loadout.actions ?? {}).length ? loadout.actions : character.actionBindings,
settings: { ...character.settings, autoTargetNearest: false } });
}
+63
View File
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import test from "node:test";
import { arenaKitForLoadout } from "./arena-character-kit.mjs";
import { ArenaSimulation } from "./arena-simulation.mjs";
import { ARENA_STAGES } from "./arena-stages.mjs";
function fixture() {
const kit = arenaKitForLoadout({classId:"priest",talents:{},actions:{"primary:face-left":"priest-flash-heal","primary:face-bottom":"priest-smite"}});
const simulation = new ArenaSimulation({id:"character-kit",stage:{ ...ARENA_STAGES[0], id:"test-floorless", lineOfSight:[] },bracket:"2v2",mode:"practice",ruleset:"wow",startedAt:0,
participants:[{actorId:"player",characterId:"saved-priest",name:"My Priest",team:0,role:"healer",isBot:false,characterKit:kit},
{actorId:"ally",team:0,role:"damage",isBot:false},{actorId:"enemy",team:1,role:"damage",isBot:false}]});
for(let i=0;i<200;i++)simulation.step();
const player=simulation.actor("player"), ally=simulation.actor("ally"), enemy=simulation.actor("enemy");
player.position=[0,0,0];ally.position=[1,0,0];enemy.position=[2,0,0];
return {simulation,player,ally,enemy,kit};
}
test("all arena spawns have finite character transforms before the first movement",()=>{
for(const stage of ARENA_STAGES) {
const simulation=new ArenaSimulation({id:stage.id,stage,bracket:"2v2",mode:"practice",ruleset:"wow",participants:[
{actorId:"left",team:0,role:"healer",isBot:false},{actorId:"right",team:1,role:"damage",isBot:false}]});
for(const actor of simulation.snapshot().actors) {
assert.ok(Number.isFinite(actor.facing),stage.id);
assert.ok(actor.position.every(Number.isFinite),stage.id);
assert.deepEqual(actor.position,stage.teamSpawns[actor.team],stage.id);
}
}
});
test("saved priest identity, spell bindings, and usable mana reach the snapshot",()=>{
const {simulation,kit}=fixture();
const player=simulation.snapshot().actors.find(actor=>actor.id==="player");
assert.equal(player.characterId,"saved-priest");assert.equal(player.classId,"priest");
assert.equal(player.actionBindings["primary:face-left"],"priest-flash-heal");
assert.ok(player.resource>kit.abilities["priest-flash-heal"].cost.amount);
assert.equal(simulation.receiveIntent("player",{sequence:1,type:"ability",abilityId:"heal"}).reason,"unknown-ability");
});
test("saved Flash Heal pays mana, has a cast time, heals the chosen ally and reports cooldown",()=>{
const {simulation,player,ally}=fixture();ally.health=200;
const resource=player.resource;
assert.equal(simulation.receiveIntent("player",{sequence:1,type:"ability",abilityId:"priest-flash-heal",targetActorId:"ally"}).accepted,true);
assert.equal(player.resource,resource);assert.equal(ally.health,200);
assert.equal(simulation.receiveIntent("player",{sequence:2,type:"ability",abilityId:"priest-flash-heal",targetActorId:"ally"}).reason,"already-casting");
for(let i=0;i<30;i++)simulation.step();
assert.ok(ally.health>200);assert.ok(player.resource<resource);assert.equal(player.health,player.maxHealth);assert.equal(player.casting,null);
});
test("movement cancels a real spell and impossible targets give feedback",()=>{
const {simulation,player,enemy}=fixture();
enemy.position=[1000,0,0];
assert.equal(simulation.receiveIntent("player",{sequence:1,type:"ability",abilityId:"priest-smite",targetActorId:"enemy"}).reason,"out-of-range");
assert.equal(simulation.receiveIntent("player",{sequence:2,type:"ability",abilityId:"priest-flash-heal",targetActorId:"player"}).accepted,true);
simulation.receiveIntent("player",{sequence:3,type:"movement",movement:{x:1,z:0}});simulation.step();
assert.equal(player.casting,null);
});
test("Shadowfiend uses the character summoning engine",()=>{
const {simulation,player}=fixture();
player.facing=Math.PI/2;
assert.equal(simulation.receiveIntent("player",{sequence:1,type:"ability",abilityId:"wow335-priest-shadowfiend",targetActorId:"enemy"}).accepted,true);
assert.ok(player.combat.summons.length>0);
});
+171
View File
@@ -0,0 +1,171 @@
import geometry from "./generated/arena-geometry.json" with { type: "json" };
const CELL = 4;
const indexes = new Map();
const navigationGraphs = new Map();
function index(stageId, kind) {
const key = `${stageId}:${kind}`;
if (indexes.has(key)) return indexes.get(key);
const triangles = geometry[stageId]?.[kind] ?? [];
const cells = new Map();
for (let offset = 0; offset < triangles.length; offset += 9) {
const xs = [triangles[offset], triangles[offset + 3], triangles[offset + 6]];
const zs = [triangles[offset + 2], triangles[offset + 5], triangles[offset + 8]];
for (let x = Math.floor(Math.min(...xs) / CELL); x <= Math.floor(Math.max(...xs) / CELL); x++) {
for (let z = Math.floor(Math.min(...zs) / CELL); z <= Math.floor(Math.max(...zs) / CELL); z++) {
const cell = `${x},${z}`;
if (!cells.has(cell)) cells.set(cell, []);
cells.get(cell).push(offset);
}
}
}
const value = { triangles, cells };
indexes.set(key, value);
return value;
}
export function arenaFloorHeight(stageId, x, z, previousY, maximumRise = 1) {
const { triangles: t, cells } = index(stageId, "navigation");
let height = null;
for (const o of cells.get(`${Math.floor(x / CELL)},${Math.floor(z / CELL)}`) ?? []) {
const ax = t[o], az = t[o + 2], bx = t[o + 3], bz = t[o + 5], cx = t[o + 6], cz = t[o + 8];
const denominator = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz);
if (Math.abs(denominator) < .000001) continue;
const a = ((bz - cz) * (x - cx) + (cx - bx) * (z - cz)) / denominator;
const b = ((cz - az) * (x - cx) + (ax - cx) * (z - cz)) / denominator;
if (a < -.001 || b < -.001 || a + b > 1.001) continue;
const y = a * t[o + 1] + b * t[o + 4] + (1 - a - b) * t[o + 7];
if (y > previousY + maximumRise) continue;
if (height === null || y > height) height = y;
}
return height;
}
function hitsTriangle(from, to, t, o) {
const dx = to[0] - from[0], dy = to[1] - from[1], dz = to[2] - from[2];
const e1x = t[o + 3] - t[o], e1y = t[o + 4] - t[o + 1], e1z = t[o + 5] - t[o + 2];
const e2x = t[o + 6] - t[o], e2y = t[o + 7] - t[o + 1], e2z = t[o + 8] - t[o + 2];
const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x;
const determinant = e1x * px + e1y * py + e1z * pz;
if (Math.abs(determinant) < 1e-8) return false;
const inverse = 1 / determinant, sx = from[0] - t[o], sy = from[1] - t[o + 1], sz = from[2] - t[o + 2];
const u = (sx * px + sy * py + sz * pz) * inverse;
if (u < 0 || u > 1) return false;
const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
const v = (dx * qx + dy * qy + dz * qz) * inverse;
if (v < 0 || u + v > 1) return false;
const distance = (e2x * qx + e2y * qy + e2z * qz) * inverse;
return distance > .0001 && distance < .9999;
}
export function arenaGeometryLineClear(stageId, from, to) {
const { triangles, cells } = index(stageId, "collision");
const steps = Math.max(1, Math.ceil(Math.hypot(to[0] - from[0], to[2] - from[2]) / (CELL * .5)));
const visited = new Set();
for (let step = 0; step <= steps; step++) {
const ratio = step / steps;
const cell = `${Math.floor((from[0] + (to[0] - from[0]) * ratio) / CELL)},${Math.floor((from[2] + (to[2] - from[2]) * ratio) / CELL)}`;
for (const offset of cells.get(cell) ?? []) {
if (visited.has(offset)) continue;
visited.add(offset);
if (hitsTriangle(from, to, triangles, offset)) return false;
}
}
return true;
}
function navigationGraph(stageId) {
if (navigationGraphs.has(stageId)) return navigationGraphs.get(stageId);
const triangles = geometry[stageId]?.navigation ?? [];
const nodes = [], edges = new Map();
for (let offset = 0; offset < triangles.length; offset += 9) {
const vertices = [0, 3, 6].map((axis) => triangles.slice(offset + axis, offset + axis + 3));
const center = [0, 1, 2].map((axis) => vertices.reduce((sum, point) => sum + point[axis], 0) / 3);
const node = { id: nodes.length, center, neighbors: [], vertices };
nodes.push(node);
for (let edge = 0; edge < 3; edge++) {
const a = vertices[edge], b = vertices[(edge + 1) % 3];
const key = [a.join(","), b.join(",")].sort().join("/");
const portal = a.map((value, axis) => (value + b[axis]) / 2);
for (const other of edges.get(key) ?? []) {
node.neighbors.push({ id: other.id, portal });
other.neighbors.push({ id: node.id, portal });
}
if (!edges.has(key)) edges.set(key, []);
edges.get(key).push(node);
}
}
navigationGraphs.set(stageId, nodes);
return nodes;
}
const distance3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
function navigationStepClear(stageId, from, to) {
let height = from[1];
const steps = Math.max(1, Math.ceil(distance3(from, to) / .4));
for (let step = 1; step <= steps; step++) {
const ratio = step / steps;
const x = from[0] + (to[0] - from[0]) * ratio, z = from[2] + (to[2] - from[2]) * ratio;
const floor = arenaFloorHeight(stageId, x, z, height, .6);
if (floor === null || height - floor > .8) return false;
height = floor;
}
return Math.abs(height - to[1]) < .8;
}
function navigationNode(nodes, point) {
let nearest = null, nearestDistance = Infinity;
for (const node of nodes) {
const [a, b, c] = node.vertices;
const denominator = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]);
if (Math.abs(denominator) < .000001) continue;
const u = ((b[2] - c[2]) * (point[0] - c[0]) + (c[0] - b[0]) * (point[2] - c[2])) / denominator;
const v = ((c[2] - a[2]) * (point[0] - c[0]) + (a[0] - c[0]) * (point[2] - c[2])) / denominator;
const inside = u >= -.001 && v >= -.001 && u + v <= 1.001;
const height = u * a[1] + v * b[1] + (1 - u - v) * c[1];
const distance = inside ? Math.abs(height - point[1]) : distance3(node.center, point) + 5;
if (distance < nearestDistance) { nearest = node; nearestDistance = distance; }
}
return nearest;
}
/** Follow the shipped walkable mesh around walls, ramps and starting rooms. */
export function arenaNavigationPath(stageId, from, to) {
const nodes = navigationGraph(stageId);
const start = navigationNode(nodes, from), goal = navigationNode(nodes, to);
if (!start || !goal) return [];
const open = new Set([start.id]), costs = new Map([[start.id, 0]]), previous = new Map();
let reached = false;
while (open.size) {
let current = null, score = Infinity;
for (const id of open) {
const candidate = costs.get(id) + distance3(nodes[id].center, goal.center);
if (candidate < score) { current = nodes[id]; score = candidate; }
}
if (current.id === goal.id) { reached = true; break; }
open.delete(current.id);
for (const edge of current.neighbors) {
const next = nodes[edge.id];
edge.walkable ??= navigationStepClear(stageId, current.center, edge.portal) && navigationStepClear(stageId, edge.portal, next.center) && [.55, 1.4].every((height) => {
const lift = (point) => [point[0], point[1] + height, point[2]];
return arenaGeometryLineClear(stageId, lift(current.center), lift(edge.portal))
&& arenaGeometryLineClear(stageId, lift(edge.portal), lift(next.center));
});
if (!edge.walkable) continue;
const cost = costs.get(current.id) + distance3(current.center, edge.portal) + distance3(edge.portal, next.center);
if (cost >= (costs.get(next.id) ?? Infinity)) continue;
costs.set(next.id, cost);
previous.set(next.id, { id: current.id, portal: edge.portal });
open.add(next.id);
}
}
if (!reached) return [];
const path = [[...to]];
for (let id = goal.id; id !== start.id;) {
const edge = previous.get(id);
path.push(edge.portal);
id = edge.id;
}
return path.reverse();
}
+52
View File
@@ -0,0 +1,52 @@
import test from "node:test";
import assert from "node:assert/strict";
import { arenaFloorHeight, arenaGeometryLineClear, arenaNavigationPath } from "./arena-geometry.mjs";
import { ARENA_STAGES } from "./arena-stages.mjs";
import { ArenaSimulation } from "./arena-simulation.mjs";
test("every arena spawn is supported by its shipped navigation surface", () => {
for (const stage of ARENA_STAGES) for (const spawn of stage.teamSpawns) {
const floor = arenaFloorHeight(stage.id, spawn[0], spawn[2], spawn[1], 2);
assert.notEqual(floor, null, `${stage.id} ${spawn}`);
assert.ok(Math.abs(floor - spawn[1]) < 2, `${stage.id} floor=${floor}, spawn=${spawn[1]}`);
}
});
test("characters can move away from both starting anchors into each arena", () => {
for (const stage of ARENA_STAGES) for (const team of [0, 1]) {
const sim = new ArenaSimulation({ id: "traversal", stage, bracket: "2v2", mode: "practice", ruleset: "wow", startedAt: 1000,
participants: [{ actorId: "player", name: "Player", team, role: "healer", isBot: false }] });
sim.phase = "active"; sim.activeStartedAt = 1000;
const actor = sim.actor("player"), start = [...actor.position], gate = stage.preparationGates[team];
const dx = gate[0] - start[0], dz = gate[2] - start[2], length = Math.hypot(dx, dz);
actor.movement = [dx / length, dz / length];
for (let tick = 0; tick < 50; tick++) sim.moveActor(actor, .05);
assert.ok(Math.hypot(actor.position[0] - start[0], actor.position[2] - start[2]) > 3, `${stage.id} team ${team}: ${actor.position} from ${start}`);
assert.ok(actor.position.every(Number.isFinite));
}
});
test("collision geometry blocks arena walls and preserves clear short sight lines", () => {
const stage = ARENA_STAGES.find((entry) => entry.id === "nagrand-arena");
const spawn = stage.teamSpawns[0];
assert.equal(arenaGeometryLineClear(stage.id, [spawn[0], spawn[1] + 1.2, spawn[2]], [spawn[0] + .05, spawn[1] + 1.2, spawn[2]]), true);
assert.equal(arenaGeometryLineClear(stage.id, [-60, 14.5, -42], [-35, 14.5, -42]), false);
});
test("bots can traverse each complete arena between both teams without invisible walls", () => {
for (const stage of ARENA_STAGES) for (const team of [0, 1]) {
const sim = new ArenaSimulation({ id: "path", stage, bracket: "3v3", mode: "practice", ruleset: "wow", startedAt: 1000,
participants: [0, 1, 2].map((index) => ({ actorId: `p${index}`, name: "Player", team, role: "damage", isBot: false })) });
sim.phase = "active"; sim.activeStartedAt = 1000;
for (const actor of sim.actors.values()) assert.notEqual(arenaFloorHeight(stage.id, actor.position[0], actor.position[2], actor.position[1], 2), null, `${stage.id} team ${team} ${actor.id} spawn`);
const actor = sim.actor("p0"), destination = stage.teamSpawns[1 - team];
actor.navigationPath = arenaNavigationPath(stage.id, actor.position, destination);
assert.ok(actor.navigationPath.length, `${stage.id} has a route`);
for (let tick = 0; tick < 2400; tick++) {
sim.tickNumber++;
sim.followBotPath(actor); sim.moveActor(actor, .05);
if (Math.hypot(actor.position[0] - destination[0], actor.position[2] - destination[2]) < 2) break;
}
assert.ok(Math.hypot(actor.position[0] - destination[0], actor.position[2] - destination[2]) < 2, `${stage.id} team ${team} stuck at ${actor.position}`);
}
});
+185
View File
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ArenaSimulation } from "./arena-simulation.mjs";
import { ARENA_STAGES } from "./arena-stages.mjs";
import { initializeArenaCharacter } from "./generated/arena-combat-runtime.mjs";
function match(classId = "priest", secondaryClassId) {
const simulation = new ArenaSimulation({ id: `runtime-${classId}`, stage: { ...ARENA_STAGES[0], id: "test-floorless", lineOfSight: [] },
bracket: "2v2", mode: "practice", ruleset: "wow", startedAt: 1000,
participants: [{ actorId: "caster", name: "My Character", team: 0, role: "healer", isBot: false,
characterKit: { classId, level: 80, seed: { classId, secondaryClassId, level: 80, actionBindings: { "primary:face-left": "priest-flash-heal" } } } },
{ actorId: "ally", name: "Ally", team: 0, role: "damage", isBot: false },
{ actorId: "enemy", name: "Enemy", team: 1, role: "damage", isBot: false, characterKit: { classId: "mage", level: 80 } }] });
simulation.phase = "active"; simulation.activeStartedAt = 1000; simulation.phaseEndsAt = null;
const caster = simulation.actor("caster"), ally = simulation.actor("ally"), enemy = simulation.actor("enemy");
caster.position = [0, 0, 0]; caster.facing = 0; ally.position = [1, 0, 0]; enemy.position = [0, 0, 3]; enemy.facing = Math.PI;
const cast = (id, target = "enemy", ground) => simulation.useAbility(caster, id, target, ground);
const advance = (ms) => { for (let index = 0; index < ms / 50; index++) simulation.step(); };
return { simulation, caster, ally, enemy, cast, advance };
}
test("melee bots start weapon attacks and generate resources instead of waiting at zero rage", () => {
const { caster, enemy, advance } = match("warrior");
caster.isBot = true; caster.role = "damage";
const health = enemy.health;
advance(8000);
assert.ok(enemy.health < health);
assert.ok(caster.combat.combatEvents.length > 0 || caster.combat.autoAttackTargetId === enemy.id);
});
test("friendly dispels remove the movement restriction as well as the visible debuff", () => {
const { simulation, caster, enemy, cast } = match("priest");
assert.equal(simulation.useAbility(enemy, "mage-frost-nova", caster.id).accepted, true);
assert.equal(caster.statuses.find((status) => status.kind === "root")?.endsAt, 9000);
const dispel = cast("wow335-priest-dispel-magic", caster.id);
assert.equal(dispel.accepted, true, JSON.stringify(dispel));
assert.equal(caster.statuses.some((status) => status.kind === "root"), false);
caster.movement = [1, 0]; simulation.moveActor(caster, .05);
assert.ok(caster.position[0] > 0);
});
test("player crowd control diminishes once per cast, including duplicated catalog status entries", () => {
const { simulation, caster, enemy, advance } = match("priest");
for (const duration of [8000, 4000, 2000, 0]) {
enemy.cooldowns.clear(); enemy.combat = { ...enemy.combat, cooldowns: {}, globalCooldownEndsAt: 0 };
assert.equal(simulation.useAbility(enemy, "mage-frost-nova", caster.id).accepted, true);
const now = simulation.startedAt + simulation.tickNumber * 50;
assert.equal((caster.statuses.find((status) => status.kind === "root")?.endsAt ?? now) - now, duration);
advance(duration + 50);
}
});
test("a healer can target and heal a teammate's pet", () => {
const { simulation, caster, ally, cast, advance } = match("hunter");
assert.equal(cast("wow335-hunter-call-pet", caster.id).accepted, true);
advance(2000);
const pet = caster.combat.summons[0];
caster.combat = { ...caster.combat, summons: [{ ...pet, health: 500 }] };
ally.combat = null; ally.characterKit = { classId: "priest", level: 80 };
initializeArenaCharacter(ally, 3000);
assert.equal(simulation.useAbility(ally, "priest-flash-heal", pet.id).accepted, true);
advance(2000);
assert.ok(caster.combat.summons[0].health > 500);
});
test("channel deals successive damage, heals its caster, and stops on movement", () => {
const { caster, enemy, cast, advance } = match("warlock");
caster.health = 3000;
assert.equal(cast("warlock-drain-life").accepted, true);
assert.equal(caster.casting.mode, "channel");
const initial = enemy.health;
advance(2200);
assert.ok(enemy.health < initial);
assert.ok(caster.health > 3000);
caster.movement = [1, 0]; advance(50);
assert.equal(caster.casting, null);
});
test("runes are spent, gate repeat casts, regenerate, and restore with Empower Rune Weapon", () => {
const { caster, cast, advance } = match("death-knight");
assert.equal(cast("death-knight-icy-touch").accepted, true);
assert.ok(caster.combat.deathKnightRunes.some((rune) => rune.readyAt > 1000));
assert.ok(caster.resource > 0);
advance(1600);
assert.equal(cast("wow335-death-knight-empower-rune-weapon", "caster").accepted, true);
assert.ok(caster.combat.deathKnightRunes.every((rune) => rune.readyAt <= 2600));
});
test("combo points belong to the selected enemy and finishers consume them", () => {
const { caster, cast, advance } = match("rogue");
assert.equal(cast("rogue-sinister-strike").accepted, true);
assert.equal(caster.combat.comboPoints, 1);
assert.equal(caster.combat.comboTargetId, "enemy");
advance(1600);
assert.equal(cast("rogue-eviscerate").accepted, true);
assert.equal(caster.combat.comboPoints, 0);
});
test("pets spawn, receive orders, attack, and dismiss through the same spell engine", () => {
const { simulation, caster, enemy, cast, advance } = match("hunter");
assert.equal(cast("wow335-hunter-call-pet", "caster").accepted, true);
assert.ok(caster.combat.summons.some((summon) => summon.kind === "pet"));
simulation.receiveIntent("caster", { type: "target", targetActorId: "enemy", sequence: 1 });
simulation.receiveIntent("caster", { type: "command", command: "attack", sequence: 2 });
const before = enemy.health;
advance(3100);
assert.ok(enemy.health < before);
assert.equal(cast("wow335-hunter-dismiss-pet", "caster").accepted, true);
advance(Math.max(50, (caster.casting?.completesAt ?? 0) - (1000 + simulation.tickNumber * 50) + 50));
assert.equal(caster.combat.summons.length, 0);
});
test("an opposing player's interrupt cancels the cast and locks its school", () => {
const { simulation, caster, enemy, cast, advance } = match();
assert.equal(cast("priest-smite").accepted, true);
assert.equal(simulation.useAbility(enemy, "wow335-mage-counterspell", "caster").accepted, true);
assert.equal(caster.casting, null);
assert.ok(caster.combat.schoolLockedUntil.holy > 1000);
advance(1600);
assert.equal(cast("priest-smite").accepted, false);
});
test("healing reaches the requested teammate, is dampened, and does not alter another match", () => {
const first = match(), second = match();
first.ally.health = 500;
const untouched = second.caster.resource;
assert.equal(first.cast("priest-flash-heal", "ally").accepted, true);
first.advance(1600);
assert.ok(first.ally.health > 500);
assert.ok(first.caster.resource < untouched);
assert.equal(second.caster.resource, untouched);
assert.equal(second.caster.casting, null);
assert.ok(first.simulation.combatEvents.some((event) => event.kind === "healing" && event.targetActorId === "ally" && event.sourceActorId === "caster"));
assert.ok(first.simulation.combatPresentation.some((event) => event.sourceActorId === "caster"));
});
test("self shields absorb hostile-player damage and are consumed once", () => {
const { simulation, caster, enemy, cast, advance } = match("mage");
assert.equal(cast("mage-mana-shield", "caster").accepted, true);
const shield = caster.combat.shield;
assert.ok(shield > 0 || caster.auras.some((aura) => aura.absorbs.length));
const health = caster.health;
enemy.combat = { ...enemy.combat, gearStats: { ...enemy.combat.gearStats, hitRating: 100000 } };
assert.equal(simulation.useAbility(enemy, "mage-fire-blast", "caster").accepted, true);
assert.ok(caster.health >= health - 1010);
assert.ok(simulation.combatEvents.some((event) => event.kind === "damage" && event.targetActorId === "caster" && event.absorbed > 0), JSON.stringify(simulation.combatEvents));
advance(50);
});
test("RoM primary and secondary resource pools survive arena ticks and casting", () => {
const { caster, cast, advance } = match("rom-warlock", "rom-priest");
assert.ok(Object.keys(caster.combat.resourcePools).length > 1);
const focus = caster.combat.resourcePools.focus;
assert.equal(cast("rom-warlock-general-0").accepted, true);
advance(4000);
assert.ok(Number.isFinite(caster.combat.resourcePools.focus));
assert.ok(caster.combat.resourcePools.focus <= focus);
assert.ok(Number.isFinite(caster.combat.resourcePools.mana));
});
test("ground spells damage targets around the confirmed location", () => {
const { caster, enemy, cast } = match("mage");
const health = enemy.health;
assert.equal(cast("wow335-mage-blizzard", null, enemy.position).accepted, true);
assert.ok(enemy.health < health);
assert.ok(caster.resource >= 0);
});
test("mana burns drain an opposing character's actual resource pool", () => {
const { enemy, cast, advance } = match();
const before = enemy.resource;
assert.equal(cast("wow335-priest-mana-burn").accepted, true);
advance(3500);
assert.ok(enemy.resource < before);
});
test("opponents can target and damage player pets", () => {
const { simulation, caster, enemy, cast } = match("hunter");
assert.equal(cast("wow335-hunter-call-pet", "caster").accepted, true);
const pet = caster.combat.summons[0], before = pet.health;
enemy.combat = { ...enemy.combat, gearStats: { ...enemy.combat.gearStats, hitRating: 100000 } };
assert.equal(simulation.receiveIntent("enemy", { sequence: 1, type: "target", targetActorId: pet.id }).accepted, true);
assert.equal(simulation.useAbility(enemy, "mage-fire-blast", pet.id).accepted, true);
assert.ok((caster.combat.summons.find((entry) => entry.id === pet.id)?.health ?? 0) < before);
});
+17 -12
View File
@@ -3,6 +3,7 @@ import { GameDatabaseError } from "./database.mjs";
import { ARENA_BRACKETS, arenaMatchTolerance, assertArenaQueueInput, validateArenaComposition } from "./arena-rules.mjs";
import { ARENA_STAGES, chooseArenaStage } from "./arena-stages.mjs";
import { ArenaSimulation } from "./arena-simulation.mjs";
import { arenaKitForLoadout } from "./arena-character-kit.mjs";
const READY_CHECK_MS = 15_000;
const PVP_BOT_FILL_MS = 30_000;
@@ -222,18 +223,22 @@ export function createArenaService({ database, random = Math.random, now = () =>
}
function fillTeam(humans, team, bracket, rating) {
const members = humans.map((member) => ({
actorId: `player-${randomUUID()}`,
accountId: member.accountId,
characterId: member.characterId,
name: member.loadout.name || member.username,
team,
role: member.role,
isBot: false,
disconnected: false,
rating: database.getArenaRating(member.accountId, member.characterId, bracket).rating,
loadout: member.loadout,
}));
const members = humans.map((member) => {
const character = database.getCloudSave?.(member.accountId)?.data?.characters?.find((entry) => entry.id === member.characterId) ?? {};
return ({
actorId: `player-${randomUUID()}`,
accountId: member.accountId,
characterId: member.characterId,
name: character.name || member.username,
characterKit: arenaKitForLoadout(member.loadout, character),
team,
role: member.role,
isBot: false,
disconnected: false,
rating: database.getArenaRating(member.accountId, member.characterId, bracket).rating,
loadout: member.loadout,
});
});
const composition = validateArenaComposition(bracket, members.map((member) => member.role), { allowPartial: true });
if (!composition.ok) throw new GameDatabaseError(composition.message, 400, composition.code);
for (let index = 0; index < composition.missing.healer; index += 1) members.push(botMember(team, "healer", rating, index));
+3 -1
View File
@@ -1,5 +1,6 @@
import type {
ArenaAuthoritativeSnapshot,
ArenaCharacterKit,
ArenaCombatIntent,
ArenaResult,
ArenaRole,
@@ -8,6 +9,7 @@ import type {
import type { ArenaBracket, ArenaMode, ArenaRuleset } from "../src/game/arenaRules";
export interface ArenaSimulationParticipant {
characterKit?: ArenaCharacterKit;
actorId: string;
accountId?: string | null;
characterId?: string | null;
@@ -43,5 +45,5 @@ export declare class ArenaSimulation {
stop(): void;
step(): void;
snapshot(): ArenaAuthoritativeSnapshot;
receiveIntent(actorId: string, intent: ArenaCombatIntent): { accepted: boolean; reason?: string };
receiveIntent(actorId: string, intent: ArenaCombatIntent): { accepted: boolean; reason?: string; message?: string };
}
+198 -108
View File
@@ -1,19 +1,11 @@
import { arenaAiProfile, arenaDampening, arenaDrDuration, applyArenaHealing, shouldArenaBotChooseCorrectly } from "./arena-rules.mjs";
import { arenaAiProfile, arenaDampening, shouldArenaBotChooseCorrectly } from "./arena-rules.mjs";
import { initializeArenaCharacter, useArenaCharacterAbility, tickArenaCharacter, arenaCharacterSnapshot, damageArenaCharacter, commandArenaCharacter, bindArenaCharacter } from "./generated/arena-combat-runtime.mjs";
import { arenaFloorHeight, arenaGeometryLineClear, arenaNavigationPath } from "./arena-geometry.mjs";
export const ARENA_TICK_RATE = 20;
export const ARENA_SNAPSHOT_RATE = 10;
export const ARENA_TICK_MS = 1000 / ARENA_TICK_RATE;
const ABILITIES = Object.freeze({
attack: Object.freeze({ cooldownMs: 1500, range: 5, damage: 40 }),
strike: Object.freeze({ cooldownMs: 6000, range: 5, damage: 120 }),
"ranged-attack": Object.freeze({ cooldownMs: 2200, range: 30, damage: 52 }),
heal: Object.freeze({ cooldownMs: 0, range: 40, healing: 150, castMs: 1500, resource: 12 }),
interrupt: Object.freeze({ cooldownMs: 15000, range: 8, lockoutMs: 4000 }),
control: Object.freeze({ cooldownMs: 30000, range: 25, controlMs: 6000, drCategory: "incapacitate" }),
defensive: Object.freeze({ cooldownMs: 90000, range: 0, defensiveMs: 8000 }),
});
function distance2d(left, right) {
return Math.hypot(left.position[0] - right.position[0], left.position[2] - right.position[2]);
}
@@ -34,26 +26,34 @@ function segmentIntersectsBox(from, to, object) {
&& Math.abs(nearestZ - object.position[2]) <= radiusZ;
}
function newActor(participant, spawn, teamIndex, arenaCenter) {
const offset = participant.role === "healer" ? -2 : teamIndex * 2;
const facing = Math.atan2(arenaCenter[0] - spawn[0], arenaCenter[2] - spawn[2]);
function newActor(participant, spawn, teamIndex, preparationGate) {
const facing = Math.atan2(preparationGate[0] - spawn[0], preparationGate[2] - spawn[2]);
// Keep the first player on the validated anchor. Role-based sideways offsets
// put healers inside the starting-room wall in Nagrand.
const offset = teamIndex * 2;
return {
id: participant.actorId,
accountId: participant.accountId ?? null,
characterId: participant.characterId ?? null,
characterKit: participant.characterKit ?? null,
name: participant.name,
team: participant.team,
role: participant.role,
isBot: Boolean(participant.isBot),
disconnected: Boolean(participant.disconnected),
alive: true,
position: [spawn[0], spawn[1], spawn[2] + offset],
position: [spawn[0] + Math.sin(facing) * offset, spawn[1], spawn[2] + Math.cos(facing) * offset],
facing,
movement: [0, 0],
health: 1000,
maxHealth: 1000,
resource: 100,
maxResource: 100,
resource: participant.characterKit?.resource?.initial ?? 100,
maxResource: participant.characterKit?.resource?.maximum ?? 100,
gcdUntil: 0,
lastAbilityId: undefined,
abilityRevision: 0,
auras: [],
periodic: new Map(),
targetActorId: null,
cooldowns: new Map(),
effects: new Map(),
@@ -134,14 +134,13 @@ export class ArenaSimulation {
this.actors = new Map();
this.lastMechanicPulse = new Map();
const teamCounts = [0, 0];
const arenaCenter = [
(this.stage.bounds.min[0] + this.stage.bounds.max[0]) / 2,
(this.stage.bounds.min[2] + this.stage.bounds.max[2]) / 2,
];
for (const participant of options.participants) {
const team = participant.team;
const actor = newActor(participant, this.stage.teamSpawns[team], teamCounts[team]++, arenaCenter);
const teamIndex = teamCounts[team]++;
const actor = newActor(participant, this.stage.teamSpawns[team], teamIndex, this.stage.preparationGates[team]);
if (teamIndex) actor.position[1] = arenaFloorHeight(this.stage.id, actor.position[0], actor.position[2], actor.position[1], 2) ?? actor.position[1];
this.actors.set(actor.id, actor);
initializeArenaCharacter(actor, this.startedAt);
}
for (const object of this.stage.dynamicObjects) {
if (object.initialDelayRangeMs) {
@@ -173,19 +172,68 @@ export class ArenaSimulation {
}
hasLineOfSight(left, right, dynamicStates = null) {
if (!arenaGeometryLineClear(this.stage.id, [left.position[0], left.position[1] + 1.2, left.position[2]], [right.position[0], right.position[1] + 1.2, right.position[2]])) return false;
const states = dynamicStates ?? new Map(this.dynamicObjects().map((state) => [state.id, state]));
return !this.stage.lineOfSight.some((object) => {
if (object.dynamicObjectId && !states.get(object.dynamicObjectId)?.collisionEnabled) return false;
// Static walls come from the real mesh. Old proxy boxes created invisible
// obstacles and blocked routes that are open in the detailed arena.
if (!object.dynamicObjectId || !states.get(object.dynamicObjectId)?.collisionEnabled) return false;
return segmentIntersectsBox(left.position, right.position, object);
});
}
targetActor(actorId) {
const actor = this.actor(actorId);
if (actor) return actor;
for (const owner of this.actors.values()) {
const summon = owner.combat.summons.find((candidate) => candidate.id === actorId);
if (summon) return { ...owner, ...summon, team: owner.team, alive: summon.health > 0, facing: owner.facing };
}
return null;
}
projectGroundPoint(origin, requested, range) {
if (Math.hypot(requested[0] - origin[0], requested[2] - origin[2]) > range) return null;
if ([0, 2].some((axis) => requested[axis] < this.stage.bounds.min[axis] || requested[axis] > this.stage.bounds.max[axis])) return null;
const floor = arenaFloorHeight(this.stage.id, requested[0], requested[2], requested[1], 2);
if (floor === null && arenaFloorHeight(this.stage.id, origin[0], origin[2], origin[1], 2) !== null) return null;
const point = [requested[0], floor ?? requested[1], requested[2]];
return this.hasLineOfSight({ position: origin }, { position: point }) ? point : null;
}
resolvePosition(actor, requested, teleport = false) {
const x = Math.max(this.stage.bounds.min[0] + .35, Math.min(this.stage.bounds.max[0] - .35, requested[0]));
const z = Math.max(this.stage.bounds.min[2] + .35, Math.min(this.stage.bounds.max[2] - .35, requested[2]));
const floor = arenaFloorHeight(this.stage.id, x, z, actor.position[1], teleport ? 3 : 1);
const oldFloor = arenaFloorHeight(this.stage.id, actor.position[0], actor.position[2], actor.position[1], 2);
if (floor === null && oldFloor !== null) return [...actor.position];
const ground = floor ?? actor.groundY ?? actor.position[1];
const target = [x, Math.max(ground, requested[1]), z];
for (const height of [.55, 1.4]) {
const from = [actor.position[0], actor.position[1] + height, actor.position[2]];
const to = [target[0], target[1] + height, target[2]];
if (!arenaGeometryLineClear(this.stage.id, from, to)) return [...actor.position];
}
const dynamic = new Map(this.dynamicObjects().map((entry) => [entry.id, entry]));
for (const object of this.stage.lineOfSight) {
if (!object.dynamicObjectId || !dynamic.get(object.dynamicObjectId)?.collisionEnabled) continue;
if (target[1] > object.position[1] + object.size[1] / 2 || target[1] + 1.8 < object.position[1] - object.size[1] / 2) continue;
if (Math.abs(x - object.position[0]) < object.size[0] / 2 + .35 && Math.abs(z - object.position[2]) < object.size[2] / 2 + .35) return [...actor.position];
}
actor.groundY = ground;
return target;
}
receiveIntent(actorId, intent) {
const actor = this.actor(actorId);
if (!actor || actor.isBot || actor.disconnected || !actor.alive || this.phase !== "active") return { accepted: false, reason: "actor-unavailable" };
if (!actor || actor.isBot || actor.disconnected || !actor.alive || !["active", "preparation"].includes(this.phase)) return { accepted: false, reason: "actor-unavailable" };
const sequence = Math.trunc(Number(intent?.sequence));
if (!Number.isSafeInteger(sequence) || sequence <= actor.lastSequence) return { accepted: false, reason: "stale-sequence" };
actor.lastSequence = sequence;
if (intent.type === "bindings" && intent.actionBindings && typeof intent.actionBindings === "object") {
bindArenaCharacter(actor, intent.actionBindings);
return { accepted: true };
}
if (intent.type === "movement") {
const x = Math.max(-1, Math.min(1, Number(intent.movement?.x) || 0));
const z = Math.max(-1, Math.min(1, Number(intent.movement?.z) || 0));
@@ -198,56 +246,35 @@ export class ArenaSimulation {
return { accepted: true };
}
if (intent.type === "target") {
actor.targetActorId = this.actors.has(intent.targetActorId) ? intent.targetActorId : null;
actor.targetActorId = this.targetActor(intent.targetActorId) ? intent.targetActorId : null;
return { accepted: true };
}
if (intent.type === "ability") {
const ability = actor.characterKit.abilities[String(intent.abilityId ?? "")];
if (this.phase !== "active" && (ability?.target === "hostile" || ability?.effects.some((effect) => ["damage", "dot", "status", "execute", "finisher-damage"].includes(effect.kind)))) return { accepted: false, reason: "preparation" };
const ground = Array.isArray(intent.groundPosition) && intent.groundPosition.length === 3 && intent.groundPosition.every(Number.isFinite) ? intent.groundPosition : undefined;
return this.useAbility(actor, String(intent.abilityId ?? ""), intent.targetActorId, ground);
}
if (intent.type === "command") {
commandArenaCharacter(this, actor, intent.command, this.startedAt + this.tickNumber * ARENA_TICK_MS);
return { accepted: true };
}
if (intent.type === "jump") {
if (!actor.jumpVelocity && actor.combat.controlledUntil <= this.startedAt + this.tickNumber * ARENA_TICK_MS) actor.jumpVelocity = 7;
return { accepted: true };
}
if (intent.type === "ability") return this.useAbility(actor, String(intent.abilityId ?? ""), intent.targetActorId);
return { accepted: false, reason: "unknown-intent" };
}
useAbility(actor, abilityId, targetActorId = null) {
useAbility(actor, abilityId, targetActorId = null, groundPosition) {
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
const ability = ABILITIES[abilityId];
if (!ability || !actor.alive || now < (actor.cooldowns.get(abilityId) ?? 0) || now < actor.lockoutUntil) return { accepted: false, reason: "ability-unavailable" };
if (actor.effects.get("control")?.endsAt > now) return { accepted: false, reason: "controlled" };
const target = this.actor(targetActorId ?? actor.targetActorId) ?? (abilityId === "defensive" ? actor : null);
if (!target || !target.alive) return { accepted: false, reason: "invalid-target" };
const friendly = target.team === actor.team;
if ((ability.healing && !friendly) || (!ability.healing && abilityId !== "defensive" && friendly)) return { accepted: false, reason: "invalid-target" };
if (ability.range > 0 && distance2d(actor, target) > ability.range) return { accepted: false, reason: "out-of-range" };
if (target !== actor && !this.hasLineOfSight(actor, target)) return { accepted: false, reason: "line-of-sight" };
if (ability.resource && actor.resource < ability.resource) return { accepted: false, reason: "resource" };
if (ability.castMs) {
actor.resource -= ability.resource ?? 0;
actor.casting = { abilityId, targetActorId: target.id, completesAt: now + ability.castMs };
return { accepted: true, casting: true };
}
actor.cooldowns.set(abilityId, now + ability.cooldownMs);
if (ability.damage) this.damage(target, ability.damage);
if (ability.lockoutMs) {
if (!target.casting) return { accepted: true, interrupted: false };
target.casting = null;
target.lockoutUntil = now + ability.lockoutMs;
}
if (ability.controlMs) {
const state = target.dr.get(ability.drCategory) ?? { applications: 0, endedAt: -Infinity };
const dr = arenaDrDuration(ability.controlMs, state.applications, now - state.endedAt);
if (dr.durationMs > 0) target.effects.set("control", { id: "control", endsAt: now + dr.durationMs, drCategory: ability.drCategory });
target.dr.set(ability.drCategory, { applications: dr.applications, endedAt: now + dr.durationMs });
}
if (ability.defensiveMs) actor.effects.set("defensive", { id: "defensive", endsAt: now + ability.defensiveMs });
return { accepted: true };
if (actor.characterKit) return useArenaCharacterAbility(this, actor, abilityId, targetActorId, now, groundPosition);
return { accepted: false, reason: "actor-unavailable" };
}
damage(target, amount) {
damage(target, amount, school = "physical") {
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
const reduction = target.effects.get("defensive")?.endsAt > now ? 0.5 : 0;
target.health = Math.max(0, target.health - Math.round(amount * (1 - reduction)));
if (target.health <= 0) {
target.alive = false;
target.movement = [0, 0];
target.casting = null;
}
damageArenaCharacter(this, target, amount, school, now);
}
dynamicObjects() {
@@ -268,6 +295,8 @@ export class ArenaSimulation {
this.updateActors(simulationNow);
this.applyMechanics(simulationNow);
this.checkResult();
} else if (this.phase === "preparation") {
for (const actor of this.actors.values()) tickArenaCharacter(this, actor, simulationNow, ARENA_TICK_MS / 1000);
}
if (this.tickNumber % (ARENA_TICK_RATE / ARENA_SNAPSHOT_RATE) === 0) this.onSnapshot(this.snapshot());
}
@@ -277,30 +306,33 @@ export class ArenaSimulation {
for (const actor of this.actors.values()) {
for (const [effectId, effect] of actor.effects) if (effect.endsAt <= now) actor.effects.delete(effectId);
for (const [abilityId, endsAt] of actor.cooldowns) if (endsAt <= now) actor.cooldowns.delete(abilityId);
actor.resource = Math.min(actor.maxResource, actor.resource + 3 * deltaSeconds);
tickArenaCharacter(this, actor, now, deltaSeconds);
if (!actor.alive) continue;
if (actor.casting?.completesAt <= now) {
const cast = actor.casting;
actor.casting = null;
if (cast.abilityId === "heal") {
const target = this.actor(cast.targetActorId);
if (target?.alive && target.team === actor.team && distance2d(actor, target) <= ABILITIES.heal.range && this.hasLineOfSight(actor, target)) {
const elapsed = Math.max(0, now - this.activeStartedAt);
target.health = Math.min(target.maxHealth, target.health + applyArenaHealing(ABILITIES.heal.healing, elapsed));
}
}
}
if (!actor.casting && !(actor.effects.get("control")?.endsAt > now)) this.moveActor(actor, deltaSeconds);
if (actor.isBot && actor.navigationPath?.length && !actor.casting) this.followBotPath(actor);
if (actor.combat.controlledUntil <= now) this.moveActor(actor, deltaSeconds);
if (actor.isBot && now >= actor.nextBotDecisionAt) this.botDecision(actor, now);
}
}
moveActor(actor, deltaSeconds) {
const speed = 7;
const statuses = actor.statuses ?? [];
const speed = statuses.some((status) => status.kind === "root") ? 0 : 7 * (1 - Math.max(0, ...statuses.filter((status) => status.kind === "slow").map((status) => status.magnitude ?? 0)));
const nextX = actor.position[0] + actor.movement[0] * speed * deltaSeconds;
const nextZ = actor.position[2] + actor.movement[1] * speed * deltaSeconds;
actor.position[0] = Math.max(this.stage.bounds.min[0], Math.min(this.stage.bounds.max[0], nextX));
actor.position[2] = Math.max(this.stage.bounds.min[2], Math.min(this.stage.bounds.max[2], nextZ));
const previous = actor.position;
actor.position = this.resolvePosition(actor, [nextX, previous[1], nextZ]);
if (actor.position[0] === previous[0] && actor.position[2] === previous[2]) {
actor.position = this.resolvePosition(actor, [nextX, previous[1], previous[2]]);
actor.position = this.resolvePosition(actor, [actor.position[0], previous[1], nextZ]);
}
if (Math.hypot(...actor.movement) > 0.01) actor.facing = Math.atan2(actor.movement[0], actor.movement[1]);
actor.groundY ??= actor.position[1];
if (actor.jumpVelocity || actor.position[1] > actor.groundY + .01) {
actor.jumpVelocity ??= 0;
actor.position[1] += actor.jumpVelocity * deltaSeconds;
actor.jumpVelocity -= 20 * deltaSeconds;
if (actor.position[1] <= actor.groundY) { actor.position[1] = actor.groundY; actor.jumpVelocity = 0; }
}
}
botDecision(actor, now) {
@@ -308,44 +340,86 @@ export class ArenaSimulation {
const profile = arenaAiProfile(rating);
actor.nextBotDecisionAt = now + (profile.reactionMinSeconds + this.random() * (profile.reactionMaxSeconds - profile.reactionMinSeconds)) * 1000;
const allies = [...this.actors.values()].filter((entry) => entry.team === actor.team && entry.alive);
const enemies = [...this.actors.values()].filter((entry) => entry.team !== actor.team && entry.alive && this.hasLineOfSight(actor, entry));
const enemies = [...this.actors.values()].filter((entry) => entry.team !== actor.team && entry.alive);
const target = enemies.sort((left, right) => left.health - right.health)[0];
if (!target) {
actor.movement = [0, 0];
return;
}
actor.targetActorId = target.id;
commandArenaCharacter(this, actor, "attack", now);
const lowAlly = allies.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
if (actor.role === "healer" && lowAlly.health < lowAlly.maxHealth * 0.86) {
actor.movement = [0, 0];
this.useAbility(actor, "heal", lowAlly.id);
if (actor.characterKit) {
if (actor.casting) { actor.movement = [0, 0]; return; }
const abilities = Object.values(actor.characterKit.abilities).filter((ability) => now >= (actor.cooldowns.get(ability.id) ?? 0));
const has = (ability, kind) => ability.effects.some((effect) => effect.kind === kind);
let candidates = [];
let castTarget = target;
if (actor.role === "healer" && lowAlly.health < lowAlly.maxHealth * .86) {
candidates = abilities.filter((ability) => (ability.target === "friendly" || ability.target === "any") && (has(ability, "heal") || has(ability, "hot")));
castTarget = lowAlly;
} else if (actor.health < actor.maxHealth * .4 && shouldArenaBotChooseCorrectly(profile.correctDefensiveChance, profile.minimumErrorChance, this.random)) {
candidates = abilities.filter((ability) => ability.target === "self" && (has(ability, "heal") || has(ability, "shield") || has(ability, "apply-aura")));
castTarget = actor;
} else {
if (target.casting && shouldArenaBotChooseCorrectly(profile.healerInterruptChance, profile.minimumErrorChance, this.random)) candidates.push(...abilities.filter((ability) => has(ability, "interrupt")));
candidates.push(...abilities.filter((ability) => ability.target === "hostile" && (has(ability, "damage") || has(ability, "finisher-damage") || has(ability, "dot"))));
}
const distance = distance2d(actor, castTarget);
const range = Math.max(4, ...candidates.map((ability) => ability.range.max));
const dx = castTarget.position[0] - actor.position[0], dz = castTarget.position[2] - actor.position[2];
actor.facing = Math.atan2(dx, dz);
const needsMovement = distance > Math.min(range - .5, 24) || !this.hasLineOfSight(actor, castTarget);
if (needsMovement) {
if (!actor.navigationPath?.length || now >= (actor.nextPathAt ?? 0)) {
actor.navigationPath = arenaNavigationPath(this.stage.id, actor.position, castTarget.position);
actor.nextPathAt = now + 2500;
}
actor.movement = [dx / Math.max(.001, distance), dz / Math.max(.001, distance)];
this.followBotPath(actor);
} else { actor.navigationPath = []; actor.movement = [0, 0]; }
actor.targetActorId = castTarget.id;
for (const ability of candidates) {
if (distance <= ability.range.max || ability.target === "self") {
if (this.useAbility(actor, ability.id, castTarget.id, ability.targeting?.shape === "ground" ? castTarget.position : undefined).accepted) {
if (actor.casting) actor.movement = [0, 0];
break;
}
}
}
return;
}
const castingHealer = enemies.find((entry) => entry.role === "healer" && entry.casting);
if (castingHealer && shouldArenaBotChooseCorrectly(profile.healerInterruptChance, profile.minimumErrorChance, this.random)) {
this.useAbility(actor, "interrupt", castingHealer.id);
return;
}
canWalkLine(actor, to) {
const probe = { ...actor, position: [...actor.position] };
const distance = distance2d(actor, { position: to });
const steps = Math.max(1, Math.ceil(distance / .5));
for (let step = 1; step <= steps; step++) {
const requested = [actor.position[0] + (to[0] - actor.position[0]) * step / steps, probe.position[1], actor.position[2] + (to[2] - actor.position[2]) * step / steps];
const resolved = this.resolvePosition(probe, requested);
if (Math.hypot(resolved[0] - requested[0], resolved[2] - requested[2]) > .01) return false;
// Keep the navmesh portals on ramps and drops. A straight shortcut can
// cross an upper floor, fall, and leave the bot below its next waypoint.
if (Math.abs((probe.groundY ?? resolved[1]) - actor.position[1]) > .2) return false;
probe.position = [resolved[0], probe.groundY ?? resolved[1], resolved[2]];
}
if (actor.health < actor.maxHealth * 0.4 && shouldArenaBotChooseCorrectly(profile.correctDefensiveChance, profile.minimumErrorChance, this.random)) {
this.useAbility(actor, "defensive", actor.id);
return;
}
if (shouldArenaBotChooseCorrectly(profile.coordinatedCcChance * 0.25, profile.minimumErrorChance, this.random)) {
const healer = enemies.find((entry) => entry.role === "healer");
if (healer && this.useAbility(actor, "control", healer.id).accepted) return;
}
const distance = distance2d(actor, target);
if (distance > 4.5) {
const dx = target.position[0] - actor.position[0];
const dz = target.position[2] - actor.position[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
actor.movement = [dx / length, dz / length];
if (distance <= 30) this.useAbility(actor, "ranged-attack", target.id);
} else {
actor.movement = [0, 0];
this.useAbility(actor, "strike", target.id);
this.useAbility(actor, "attack", target.id);
return true;
}
followBotPath(actor) {
const path = actor.navigationPath;
if (!path?.length) return;
while (path.length > 1 && distance2d(actor, { position: path[0] }) < .7) path.shift();
// Smooth portals only across a collision-checked, continuously walkable line.
if ((actor.nextPathSmoothTick ?? 0) <= this.tickNumber) {
for (let index = Math.min(path.length - 1, 8); index > 0; index--) {
if (this.canWalkLine(actor, path[index])) { path.splice(0, index); break; }
}
actor.nextPathSmoothTick = this.tickNumber + 10;
}
const dx = path[0][0] - actor.position[0], dz = path[0][2] - actor.position[2], distance = Math.hypot(dx, dz);
actor.movement = distance < .2 ? [0, 0] : [dx / distance, dz / distance];
}
applyMechanics(now) {
@@ -417,6 +491,16 @@ export class ArenaSimulation {
dampening: this.phase === "active" || this.phase === "finished" ? arenaDampening(elapsed) : 0.1,
actors: [...this.actors.values()].map((actor) => ({
id: actor.id, name: actor.name, team: actor.team, role: actor.role,
characterId: actor.characterId,
avatar: actor.avatar, visualEquipment: actor.visualEquipment,
...(actor.characterKit ? {
classId: actor.characterKit.classId, level: actor.characterKit.level,
actionBindings: actor.characterKit.actionBindings, abilityIds: Object.keys(actor.characterKit.abilities),
resourceType: actor.characterKit.resource.type,
gcdRemainingMs: Math.max(0, actor.gcdUntil - now),
lastAbilityId: actor.lastAbilityId, abilityRevision: actor.abilityRevision,
combat: arenaCharacterSnapshot(actor, now),
} : {}),
isBot: actor.isBot, disconnected: actor.disconnected, alive: actor.alive,
position: [...actor.position], facing: actor.facing,
health: actor.health, maxHealth: actor.maxHealth,
@@ -424,11 +508,17 @@ export class ArenaSimulation {
targetActorId: actor.targetActorId,
cooldowns: Object.fromEntries([...actor.cooldowns].map(([id, endsAt]) => [id, Math.max(0, endsAt - now)])),
effects: [
...actor.auras.map((aura) => ({ id: aura.definition.name, icon: aura.definition.icon, disposition: aura.definition.disposition, remainingMs: aura.expiresAt == null ? 0 : Math.max(0, aura.expiresAt - now), stacks: aura.stacks })),
...(actor.statuses ?? []).map((status) => ({ id: status.kind, disposition: "debuff", remainingMs: Math.max(0, status.endsAt - now) })),
...[...actor.periodic.values()].map((effect) => ({ id: effect.id, remainingMs: Math.max(0, effect.nextAt - now) + (effect.ticks - 1) * effect.intervalMs })),
...(this.combatEffects ?? []).filter((effect) => effect.targetId === actor.id).map((effect) => ({ id: effect.sourceAbilityId, disposition: effect.kind === "dot" ? "debuff" : "buff", remainingMs: Math.max(0, effect.nextTickAt - now) + (effect.remainingTicks - 1) * effect.intervalMs })),
...[...actor.effects.values()].map((effect) => ({ id: effect.id, remainingMs: Math.max(0, effect.endsAt - now) })),
...(actor.casting ? [{ id: `casting:${actor.casting.abilityId}`, remainingMs: Math.max(0, actor.casting.completesAt - now) }] : []),
],
})),
dynamicObjects: this.dynamicObjects(),
combatPresentation: this.combatPresentation ?? [],
combatEvents: this.combatEvents ?? [],
};
}
}
+54
View File
@@ -0,0 +1,54 @@
import { createReadStream, readFileSync } from "node:fs";
import path from "node:path";
import { pipeline } from "node:stream";
import { createGunzip } from "node:zlib";
export function contentMimeType(logicalPath) {
return ({
".glb": "model/gltf-binary", ".json": "application/json", ".js": "text/javascript",
".wasm": "application/wasm", ".ktx2": "image/ktx2", ".png": "image/png",
".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml",
".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav", ".webm": "video/webm",
})[path.extname(logicalPath)] ?? "application/octet-stream";
}
function acceptsGzip(value = "") {
const encodings = new Map(String(value).split(",").map((entry) => {
const [name, ...parameters] = entry.trim().toLowerCase().split(";");
const quality = parameters.find((part) => part.trim().startsWith("q="));
return [name, quality ? Number(quality.trim().slice(2)) : 1];
}));
return (encodings.get("gzip") ?? encodings.get("*") ?? 0) > 0;
}
/** Resolve logical URLs to one immutable bundled object without expanding it on disk. */
export function createBundledContentHandler(root) {
let manifest;
return (request, response, pathname) => {
if (!["GET", "HEAD"].includes(request.method) || !/^\/(assets|basis|data)\//.test(pathname)) return false;
if (manifest === undefined) {
try { manifest = JSON.parse(readFileSync(path.join(root, "content/bootstrap-manifest.json"), "utf8")); }
catch (error) { if (error.code !== "ENOENT") throw error; manifest = null; }
}
const entry = manifest?.files[pathname];
if (!entry) return false;
const object = entry.download ?? entry;
if (!/^\/content\/bundled\/[a-f0-9]{64}\.[a-z0-9]+(?:\.g(?:z|zip))?$/.test(object.url)) return false;
const encoded = entry.download?.encoding === "gzip";
const sendGzip = encoded && acceptsGzip(request.headers["accept-encoding"]);
response.writeHead(200, {
"Content-Type": contentMimeType(pathname),
"Content-Length": sendGzip ? object.bytes : entry.bytes,
"Cache-Control": "public, max-age=3600",
"X-Content-Type-Options": "nosniff",
Vary: "Accept-Encoding",
...(sendGzip ? { "Content-Encoding": "gzip" } : {}),
});
if (request.method === "HEAD") { response.end(); return true; }
const stream = createReadStream(path.join(root, object.url.slice(1)));
const done = (error) => { if (error) response.destroy(error); };
if (encoded && !sendGzip) pipeline(stream, createGunzip(), response, done);
else pipeline(stream, response, done);
return true;
};
}
+48
View File
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { createServer, request } from "node:http";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { gzipSync, gunzipSync } from "node:zlib";
import { createBundledContentHandler } from "./bundled-content.mjs";
test("first-launch logical asset URLs serve shared gzip objects with lossless identity fallback", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "healer-bundled-server-"));
const original = Buffer.from("glTF original model bytes ".repeat(1000));
const compressed = gzipSync(original);
const objectUrl = `/content/bundled/${"a".repeat(64)}.glb.gzip`;
await mkdir(path.join(root, "content/bundled"), { recursive: true });
await writeFile(path.join(root, objectUrl.slice(1)), compressed);
await writeFile(path.join(root, "content/bootstrap-manifest.json"), JSON.stringify({ files: {
"/assets/game/dungeons/test/visual.glb": { bytes: original.length, download: { url: objectUrl, encoding: "gzip", bytes: compressed.length } },
} }));
const serve = createBundledContentHandler(root);
const server = createServer((req, res) => {
if (!serve(req, res, new URL(req.url, "http://localhost").pathname)) { res.writeHead(404); res.end(); }
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const get = (encoding, method = "GET") => new Promise((resolve, reject) => {
request({ host: "127.0.0.1", port, path: "/assets/game/dungeons/test/visual.glb?runtime=1", method, headers: { "Accept-Encoding": encoding } }, (response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("end", () => resolve({ headers: response.headers, body: Buffer.concat(chunks) }));
}).on("error", reject).end();
});
try {
const gzip = await get("gzip");
assert.equal(gzip.headers["content-encoding"], "gzip");
assert.deepEqual(gzip.body, compressed);
assert.deepEqual(gunzipSync(gzip.body), original);
const identity = await get("gzip;q=0, *;q=1");
assert.equal(identity.headers["content-encoding"], undefined);
assert.deepEqual(identity.body, original);
const head = await get("gzip", "HEAD");
assert.equal(head.body.length, 0);
assert.equal(Number(head.headers["content-length"]), compressed.length);
} finally {
await new Promise((resolve) => server.close(resolve));
await rm(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,6 @@
var ql=Object.create;var xs=Object.defineProperty;var Rl=Object.getOwnPropertyDescriptor;var jl=Object.getOwnPropertyNames;var Dl=Object.getPrototypeOf,El=Object.prototype.hasOwnProperty;var Ze=(e,a,t)=>()=>{if(t)throw t[0];try{return e&&(a=e(e=0)),a}catch(i){throw t=[i],i}};var we=(e,a)=>()=>{try{return a||e((a={exports:{}}).exports,a),a.exports}catch(t){throw a=0,t}},Yo=(e,a)=>{for(var t in a)xs(e,t,{get:a[t],enumerable:!0})},Bl=(e,a,t,i)=>{if(a&&typeof a=="object"||typeof a=="function")for(let s of jl(a))!El.call(e,s)&&s!==t&&xs(e,s,{get:()=>a[s],enumerable:!(i=Rl(a,s))||i.enumerable});return e};var Ce=(e,a,t)=>(t=e!=null?ql(Dl(e)):{},Bl(a||!e||!e.__esModule?xs(t,"default",{value:e,enumerable:!0}):t,e));function fe(e,a={}){return{...Vl,feature:e,...a}}function G(e,a,t,i,s){return{skinColor:e,face:a,hairStyle:t,hairColor:i,feature:s}}function Be(e){let a=e.featureLabel??"Features";return{id:e.id,dbcId:e.dbcId,name:e.name,faction:e.faction,genders:e.genders,unrestrictedClasses:!0,sigil:e.sigil,accent:e.accent,description:e.description,labels:{male:fe(a),female:fe(a)},counts:{male:e.male,female:e.female??e.male}}}function Vi(e){return ai.find(a=>a.id===e)??ai[0]}function fa(e){return qa.find(a=>a.id===e)??qa[0]}function Xe(e){return fa(e).archetype}function Wl(e){return Vi(e).categoryId??"wow"}function As(e){return fa(e).categoryId??"wow"}function Nl(e){return Wl(e)==="rom"}function st(e){return As(e)==="rom"}function Qo(e,a){let t=Vi(e);if(Nl(e)){if(a&&a!=="rom")return[];let o=new Set(Ol[e]);return qa.filter(r=>o.has(r.id))}if(a==="rom")return[];let i=o=>a?As(o.id)===a:As(o.id)!=="rom";if(t.unrestrictedClasses)return qa.filter(o=>i(o));let s=t.dbcId;return qa.filter(o=>i(o)&&_l[o.id].includes(s))}function Zo(e){return Vi(e).genders??["male","female"]}function Xo(){return{skinColor:0,face:0,hairStyle:0,hairColor:0,feature:0}}function er(e,a,t=Math.random){let i=Vi(e).counts[a];return Object.fromEntries(Hl.map(s=>[s,Math.floor(t()*i[s])]))}var Vl,ai,Fl,qs,qa,tt,Rs,it,_l,Ol,Hl,sa=Ze(()=>{Vl={skinColor:"Skin Color",face:"Face",hairStyle:"Hair Style",hairColor:"Hair Color",feature:"Features"};ai=[{id:"human",dbcId:1,name:"Human",faction:"alliance",sigil:"HU",accent:"#6f9dd7",description:"Versatile champions of the Alliance kingdoms.",labels:{male:fe("Facial Hair"),female:fe("Piercings")},counts:{male:G(10,12,17,10,9),female:G(10,15,24,10,7)}},{id:"orc",dbcId:2,name:"Orc",faction:"horde",sigil:"OR",accent:"#bd6450",description:"Unyielding warriors bound by strength and honor.",labels:{male:fe("Facial Hair"),female:fe("Piercings")},counts:{male:G(9,9,12,8,11),female:G(9,9,13,8,7)}},{id:"dwarf",dbcId:3,name:"Dwarf",faction:"alliance",sigil:"DW",accent:"#c58c5a",description:"Hardy mountain folk, explorers, and steadfast allies.",labels:{male:fe("Facial Hair"),female:fe("Piercings")},counts:{male:G(9,10,16,10,11),female:G(9,10,19,10,6)}},{id:"night-elf",dbcId:4,name:"Night Elf",faction:"alliance",sigil:"NE",accent:"#9275cf",description:"Ancient guardians attuned to shadow and nature.",labels:{male:fe("Facial Hair"),female:fe("Markings")},counts:{male:G(9,9,12,8,6),female:G(9,9,12,8,10)}},{id:"undead",dbcId:5,name:"Undead",faction:"horde",sigil:"UD",accent:"#8ba39d",description:"Forsaken survivors wielding grim determination.",labels:{male:fe("Features"),female:fe("Features")},counts:{male:G(6,10,15,10,17),female:G(6,10,15,10,8)}},{id:"tauren",dbcId:6,name:"Tauren",faction:"horde",sigil:"TA",accent:"#ad805c",description:"Powerful nomads guided by the Earth Mother.",labels:{male:fe("Facial Hair",{hairStyle:"Horn Style",hairColor:"Horn Color"}),female:fe("Hair",{hairStyle:"Horn Style",hairColor:"Horn Color"})},counts:{male:G(19,5,13,3,7),female:G(11,4,12,3,5)}},{id:"gnome",dbcId:7,name:"Gnome",faction:"alliance",sigil:"GN",accent:"#d28faf",description:"Brilliant inventors with courage beyond their stature.",labels:{male:fe("Facial Hair"),female:fe("Earrings")},counts:{male:G(5,7,12,9,8),female:G(5,7,12,9,7)}},{id:"troll",dbcId:8,name:"Troll",faction:"horde",sigil:"TR",accent:"#50a99a",description:"Fierce jungle hunters carrying an ancient legacy.",labels:{male:fe("Tusks"),female:fe("Tusks")},counts:{male:G(6,5,10,10,11),female:G(6,6,10,10,6)}},{id:"blood-elf",dbcId:10,name:"Blood Elf",faction:"horde",sigil:"BE",accent:"#d75f76",description:"Arcane survivors rebuilding a proud magical dynasty.",labels:{male:fe("Facial Hair"),female:fe("Earrings")},counts:{male:G(10,10,16,10,10),female:G(10,10,20,10,11)}},{id:"draenei",dbcId:11,name:"Draenei",faction:"alliance",sigil:"DR",accent:"#6986d8",description:"Exiled voyagers devoted to hope and the Light.",labels:{male:fe("Facial Hair"),female:fe("Horn Style")},counts:{male:G(14,10,14,7,8),female:G(12,10,16,7,7)}},Be({id:"goblin",dbcId:9,name:"Goblin",faction:"horde",sigil:"GO",accent:"#84ad4b",description:"Cunning cartels turn invention, nerve, and explosive ambition into power.",male:G(16,5,19,9,24),female:G(9,10,18,9,24)}),Be({id:"zandalari-troll",dbcId:12,name:"Zandalari TrLine truncated
/*! Bundled license information:
@capacitor/core/dist/index.js:
(*! Capacitor: https://capacitorjs.com/ - MIT License *)
*/
+1
View File
@@ -0,0 +1 @@
{"nagrand-arena":{"navigation":[-24.812,12.3,-40.042,-18.812,15.3,-40.842,-25.037,12.2,-41.192,-18.812,15.3,-40.842,-24.375,12.5,-49.678,-24.424,12.5,-44.649,-24.375,12.5,-49.678,-24.994,12.3,-50.66,-25.632,12.2,-49.462,-24.994,12.3,-50.66,-25.612,12.4,-51.642,-26.412,12.4,-50.842,-26.412,12.4,-50.842,-26.612,12.4,-49.242,-25.632,12.2,-49.462,-26.612,12.4,-49.242,-25.487,12.2,-43.492,-25.604,12.2,-48.253,-25.487,12.2,-43.492,-25.037,12.2,-41.192,-24.424,12.5,-44.649,-25.037,12.2,-41.192,-18.812,15.3,-40.842,-24.424,12.5,-44.649,-24.424,12.5,-44.649,-24.375,12.5,-49.678,-25.604,12.2,-48.253,-25.632,12.2,-49.462,-24.994,12.3,-50.66,-26.412,12.4,-50.842,-24.375,12.5,-49.678,-25.632,12.2,-49.462,-25.604,12.2,-48.253,-25.632,12.2,-49.462,-26.612,12.4,-49.242,-25.604,12.2,-48.253,-25.604,12.2,-48.253,-25.487,12.2,-43.492,-24.424,12.5,-44.649,-24.812,12.3,-40.042,-25.037,12.2,-41.192,-25.612,12.2,-41.985,-25.037,12.2,-41.192,-25.487,12.2,-43.492,-25.612,12.2,-41.985,-25.487,12.2,-43.492,-27.612,12.6,-46.842,-25.612,12.2,-41.985,-25.487,12.2,-43.492,-27.412,12.6,-48.442,-27.612,12.6,-46.842,-25.487,12.2,-43.492,-26.612,12.4,-49.242,-27.412,12.6,-48.442,-25.612,12.5,-52.842,-24.212,12.9,-55.242,-24.812,12.9,-54.842,-12.72,17.9,-49.719,-12.812,17.9,-56.642,-18.411,15.6,-49.426,-12.812,17.9,-56.642,-23.072,13.2,-55.382,-18.411,15.6,-49.426,-23.072,13.2,-55.382,-24.212,12.9,-55.242,-25.612,12.5,-52.842,-25.612,12.5,-52.842,-25.612,12.4,-51.642,-24.994,12.3,-50.66,-24.994,12.3,-50.66,-24.375,12.5,-49.678,-23.072,13.2,-55.382,-24.375,12.5,-49.678,-18.812,15.3,-40.842,-18.411,15.6,-49.426,-18.812,15.3,-40.842,-12.612,18,-41.642,-18.411,15.6,-49.426,-12.612,18,-41.642,-12.72,17.9,-49.719,-18.411,15.6,-49.426,-18.411,15.6,-49.426,-23.072,13.2,-55.382,-24.375,12.5,-49.678,-23.072,13.2,-55.382,-25.612,12.5,-52.842,-24.994,12.3,-50.66,-25.012,12.2,-39.642,-24.812,12.3,-40.042,-25.498,12.2,-40.585,-24.812,12.3,-40.042,-25.612,12.2,-41.985,-25.498,12.2,-40.585,-25.612,12.2,-41.985,-28.412,12.7,-46.242,-25.498,12.2,-40.585,-25.612,12.2,-41.985,-27.612,12.6,-46.842,-28.412,12.7,-46.242,-24.212,12.9,-55.242,-23.072,13.2,-55.382,-23.812,13.1,-56.842,-23.072,13.2,-55.382,-22.712,13.4,-56.822,-23.812,13.1,-56.842,-23.072,13.2,-55.382,-12.812,17.9,-56.642,-22.712,13.4,-56.822,21.988,13.6,-56.842,17.188,15.4,-56.642,18.768,14.8,-55.462,17.188,15.4,-56.642,17.157,15.6,-54.273,18.768,14.8,-55.462,17.157,15.6,-54.273,16.988,16,-41.242,18.772,15,-48.227,16.988,16,-41.242,21.788,13.8,-40.642,18.772,15,-48.227,21.788,13.8,-40.642,21.96,13.8,-54.527,18.772,15,-48.227,21.96,13.8,-54.527,21.988,13.6,-56.842,20,14.4,-54.227,21.988,13.6,-56.842,18.768,14.8,-55.462,20,14.4,-54.227,18.768,14.8,-55.462,17.157,15.6,-54.273,20,14.4,-54.227,17.157,15.6,-54.273,18.772,15,-48.227,20,14.4,-54.227,18.772,15,-48.227,21.96,13.8,-54.527,20,14.4,-54.227,21.788,13.8,-40.642,23.388,13.1,-40.442,24.455,12.8,-42.468,24.455,12.8,-42.468,28.188,13.2,-49.562,24.796,12.8,-48.229,28.188,13.2,-49.562,30.321,13.2,-53.615,28.376,13.2,-53.041,30.321,13.2,-53.615,31.388,13.4,-55.642,29.588,13.1,-56.842,29.588,13.1,-56.842,23.074,13.2,-56.842,24.772,12.8,-53.027,23.074,13.2,-56.842,21.988,13.6,-56.842,21.96,13.8,-54.527,21.96,13.8,-54.527,21.788,13.8,-40.642,23.572,13,-47.052,21.788,13.8,-40.642,24.455,12.8,-42.468,23.572,13,-47.052,24.796,12.8,-48.229,28.188,13.2,-49.562,24.792,12.8,-51.824,24.455,12.8,-42.468,24.796,12.8,-48.229,23.572,13,-47.052,28.376,13.2,-53.041,30.321,13.2,-53.615,29.588,13.1,-56.842,28.188,13.2,-49.562,28.376,13.2,-53.041,24.792,12.8,-51.824,24.772,12.8,-53.027,23.074,13.2,-56.842,23.584,13,-53.036,29.588,13.1,-56.842,24.772,12.8,-53.027,28.376,13.2,-53.041,23.074,13.2,-56.842,21.96,13.8,-54.527,23.584,13,-53.036,21.96,13.8,-54.527,23.572,13,-47.052,23.584,13,-53.036,24.796,12.8,-48.229,24.792,12.8,-51.824,23.572,13,-47.052,24.792,12.8,-51.824,28.376,13.2,-53.041,24.772,12.8,-53.027,24.772,12.8,-53.027,23.584,13,-53.036,24.792,12.8,-51.824,23.584,13,-53.036,23.572,13,-47.052,24.792,12.8,-51.824,-4.612,20.9,-51.842,-4.012,20.9,-52.442,-3.612,20.9,-56.442,-12.612,18,-41.642,-10.412,19,-41.842,-11.203,18.7,-44.661,-10.412,19,-41.842,-7.512,20.9,-46.842,-10.015,19.5,-44.653,-7.512,20.9,-46.842,-4.612,20.9,-51.842,-7.62,21,-54.223,-4.612,20.9,-51.842,-3.612,20.9,-56.442,-7.62,21,-54.223,-3.612,20.9,-56.442,-8.212,21.1,-56.542,-7.62,21,-54.223,-8.212,21.1,-56.542,-12.812,17.9,-56.642,-8.808,20.6,-54.231,-12.812,17.9,-56.642,-12.72,17.9,-49.719,-8.808,20.6,-54.231,-12.72,17.9,-49.719,-12.612,18,-41.642,-11.203,18.7,-44.661,-11.203,18.7,-44.661,-10.412,19,-41.842,-10.015,19.5,-44.653,-10.015,19.5,-44.653,-7.512,20.9,-46.842,-9.995,19.6,-47.057,-7.512,20.9,-46.842,-7.62,21,-54.223,-8.808,20.6,-54.231,-7.62,21,-54.223,-8.212,21.1,-56.542,-8.808,20.6,-54.231,-8.808,20.6,-54.231,-12.72,17.9,-49.719,-9.995,19.6,-47.057,-12.72,17.9,-49.719,-11.203,18.7,-44.661,-9.995,19.6,-47.057,-11.203,18.7,-44.661,-10.015,19.5,-44.653,-9.995,19.6,-47.057,-9.995,19.6,-47.057,-7.Line truncated
+1
View File
@@ -0,0 +1 @@
{"nagrand-arena":[-24.812,12.3,-40.042,-18.812,15.3,-40.842,-25.037,12.2,-41.192,-18.812,15.3,-40.842,-24.375,12.5,-49.678,-24.424,12.5,-44.649,-24.375,12.5,-49.678,-24.994,12.3,-50.66,-25.632,12.2,-49.462,-24.994,12.3,-50.66,-25.612,12.4,-51.642,-26.412,12.4,-50.842,-26.412,12.4,-50.842,-26.612,12.4,-49.242,-25.632,12.2,-49.462,-26.612,12.4,-49.242,-25.487,12.2,-43.492,-25.604,12.2,-48.253,-25.487,12.2,-43.492,-25.037,12.2,-41.192,-24.424,12.5,-44.649,-25.037,12.2,-41.192,-18.812,15.3,-40.842,-24.424,12.5,-44.649,-24.424,12.5,-44.649,-24.375,12.5,-49.678,-25.604,12.2,-48.253,-25.632,12.2,-49.462,-24.994,12.3,-50.66,-26.412,12.4,-50.842,-24.375,12.5,-49.678,-25.632,12.2,-49.462,-25.604,12.2,-48.253,-25.632,12.2,-49.462,-26.612,12.4,-49.242,-25.604,12.2,-48.253,-25.604,12.2,-48.253,-25.487,12.2,-43.492,-24.424,12.5,-44.649,-24.812,12.3,-40.042,-25.037,12.2,-41.192,-25.612,12.2,-41.985,-25.037,12.2,-41.192,-25.487,12.2,-43.492,-25.612,12.2,-41.985,-25.487,12.2,-43.492,-27.612,12.6,-46.842,-25.612,12.2,-41.985,-25.487,12.2,-43.492,-27.412,12.6,-48.442,-27.612,12.6,-46.842,-25.487,12.2,-43.492,-26.612,12.4,-49.242,-27.412,12.6,-48.442,-25.612,12.5,-52.842,-24.212,12.9,-55.242,-24.812,12.9,-54.842,-12.72,17.9,-49.719,-12.812,17.9,-56.642,-18.411,15.6,-49.426,-12.812,17.9,-56.642,-23.072,13.2,-55.382,-18.411,15.6,-49.426,-23.072,13.2,-55.382,-24.212,12.9,-55.242,-25.612,12.5,-52.842,-25.612,12.5,-52.842,-25.612,12.4,-51.642,-24.994,12.3,-50.66,-24.994,12.3,-50.66,-24.375,12.5,-49.678,-23.072,13.2,-55.382,-24.375,12.5,-49.678,-18.812,15.3,-40.842,-18.411,15.6,-49.426,-18.812,15.3,-40.842,-12.612,18,-41.642,-18.411,15.6,-49.426,-12.612,18,-41.642,-12.72,17.9,-49.719,-18.411,15.6,-49.426,-18.411,15.6,-49.426,-23.072,13.2,-55.382,-24.375,12.5,-49.678,-23.072,13.2,-55.382,-25.612,12.5,-52.842,-24.994,12.3,-50.66,-25.012,12.2,-39.642,-24.812,12.3,-40.042,-25.498,12.2,-40.585,-24.812,12.3,-40.042,-25.612,12.2,-41.985,-25.498,12.2,-40.585,-25.612,12.2,-41.985,-28.412,12.7,-46.242,-25.498,12.2,-40.585,-25.612,12.2,-41.985,-27.612,12.6,-46.842,-28.412,12.7,-46.242,-24.212,12.9,-55.242,-23.072,13.2,-55.382,-23.812,13.1,-56.842,-23.072,13.2,-55.382,-22.712,13.4,-56.822,-23.812,13.1,-56.842,-23.072,13.2,-55.382,-12.812,17.9,-56.642,-22.712,13.4,-56.822,21.988,13.6,-56.842,17.188,15.4,-56.642,18.768,14.8,-55.462,17.188,15.4,-56.642,17.157,15.6,-54.273,18.768,14.8,-55.462,17.157,15.6,-54.273,16.988,16,-41.242,18.772,15,-48.227,16.988,16,-41.242,21.788,13.8,-40.642,18.772,15,-48.227,21.788,13.8,-40.642,21.96,13.8,-54.527,18.772,15,-48.227,21.96,13.8,-54.527,21.988,13.6,-56.842,20,14.4,-54.227,21.988,13.6,-56.842,18.768,14.8,-55.462,20,14.4,-54.227,18.768,14.8,-55.462,17.157,15.6,-54.273,20,14.4,-54.227,17.157,15.6,-54.273,18.772,15,-48.227,20,14.4,-54.227,18.772,15,-48.227,21.96,13.8,-54.527,20,14.4,-54.227,21.788,13.8,-40.642,23.388,13.1,-40.442,24.455,12.8,-42.468,24.455,12.8,-42.468,28.188,13.2,-49.562,24.796,12.8,-48.229,28.188,13.2,-49.562,30.321,13.2,-53.615,28.376,13.2,-53.041,30.321,13.2,-53.615,31.388,13.4,-55.642,29.588,13.1,-56.842,29.588,13.1,-56.842,23.074,13.2,-56.842,24.772,12.8,-53.027,23.074,13.2,-56.842,21.988,13.6,-56.842,21.96,13.8,-54.527,21.96,13.8,-54.527,21.788,13.8,-40.642,23.572,13,-47.052,21.788,13.8,-40.642,24.455,12.8,-42.468,23.572,13,-47.052,24.796,12.8,-48.229,28.188,13.2,-49.562,24.792,12.8,-51.824,24.455,12.8,-42.468,24.796,12.8,-48.229,23.572,13,-47.052,28.376,13.2,-53.041,30.321,13.2,-53.615,29.588,13.1,-56.842,28.188,13.2,-49.562,28.376,13.2,-53.041,24.792,12.8,-51.824,24.772,12.8,-53.027,23.074,13.2,-56.842,23.584,13,-53.036,29.588,13.1,-56.842,24.772,12.8,-53.027,28.376,13.2,-53.041,23.074,13.2,-56.842,21.96,13.8,-54.527,23.584,13,-53.036,21.96,13.8,-54.527,23.572,13,-47.052,23.584,13,-53.036,24.796,12.8,-48.229,24.792,12.8,-51.824,23.572,13,-47.052,24.792,12.8,-51.824,28.376,13.2,-53.041,24.772,12.8,-53.027,24.772,12.8,-53.027,23.584,13,-53.036,24.792,12.8,-51.824,23.584,13,-53.036,23.572,13,-47.052,24.792,12.8,-51.824,-4.612,20.9,-51.842,-4.012,20.9,-52.442,-3.612,20.9,-56.442,-12.612,18,-41.642,-10.412,19,-41.842,-11.203,18.7,-44.661,-10.412,19,-41.842,-7.512,20.9,-46.842,-10.015,19.5,-44.653,-7.512,20.9,-46.842,-4.612,20.9,-51.842,-7.62,21,-54.223,-4.612,20.9,-51.842,-3.612,20.9,-56.442,-7.62,21,-54.223,-3.612,20.9,-56.442,-8.212,21.1,-56.542,-7.62,21,-54.223,-8.212,21.1,-56.542,-12.812,17.9,-56.642,-8.808,20.6,-54.231,-12.812,17.9,-56.642,-12.72,17.9,-49.719,-8.808,20.6,-54.231,-12.72,17.9,-49.719,-12.612,18,-41.642,-11.203,18.7,-44.661,-11.203,18.7,-44.661,-10.412,19,-41.842,-10.015,19.5,-44.653,-10.015,19.5,-44.653,-7.512,20.9,-46.842,-9.995,19.6,-47.057,-7.512,20.9,-46.842,-7.62,21,-54.223,-8.808,20.6,-54.231,-7.62,21,-54.223,-8.212,21.1,-56.542,-8.808,20.6,-54.231,-8.808,20.6,-54.231,-12.72,17.9,-49.719,-9.995,19.6,-47.057,-12.72,17.9,-49.719,-11.203,18.7,-44.661,-9.995,19.6,-47.057,-11.203,18.7,-44.661,-10.015,19.5,-44.653,-9.995,19.6,-47.057,-9.995,19.6,-47.057,-7.512,20.9,-46.8Line truncated
+6
View File
@@ -7,6 +7,7 @@ import { createArenaService } from "./arena-service.mjs";
import { publicManastormAdminConfig } from "./manastorm-config.mjs";
import { createOnlineSessionManager } from "./online-session.mjs";
import { upgradeArenaWebSocket } from "./websocket.mjs";
import { createBundledContentHandler } from "./bundled-content.mjs";
const MIME_TYPES = new Map([
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
@@ -148,6 +149,7 @@ function createAuthLimiter() {
export function createGameServer(options = {}) {
const staticDir = path.resolve(options.staticDir ?? process.env.STATIC_DIR ?? "dist");
const contentDir = path.resolve(options.contentDir ?? process.env.CONTENT_DIR ?? "content");
const serveBundledContent = createBundledContentHandler(staticDir);
const allowedOrigins = parseAllowedOrigins(options.corsOrigins ?? process.env.CORS_ORIGINS);
const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024));
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
@@ -344,6 +346,10 @@ export function createGameServer(options = {}) {
json(response, 404, { error: "API route not found.", code: "not_found" });
return;
}
if (serveBundledContent(request, response, url.pathname)) return;
if (url.pathname === "/content/bootstrap-manifest.json" || url.pathname.startsWith("/content/bundled/")) {
if (serveStatic(request, response, staticDir, url.pathname, { spaFallback: false, noCache: true })) return;
}
if (url.pathname === "/content" || url.pathname.startsWith("/content/")) {
const contentPath = url.pathname.slice("/content".length) || "/manifest.json";
const served = serveStatic(request, response, contentDir, contentPath, {
+16 -16
View File
@@ -9,7 +9,7 @@
"assetBaseUrl": "/assets/game/arenas/nagrand-arena",
"bounds": { "min": [-75, 8, -60], "max": [75, 26, 50] },
"teamSpawns": [[-43.41, 13.4, -42.04], [42.03, 14.5, -44.44]],
"preparationGates": [[-35, 14, -38], [34, 15, -40]],
"preparationGates": [[-38.418, 13.4, -41.764], [37.654, 14.5, -42.021]],
"spectatorPositions": [[0, 35, 42], [0, 35, -54]],
"shadowSight": [[0, 14, 18], [0, 14, -18]],
"lineOfSight": [
@@ -26,8 +26,8 @@
"mapId": 562,
"assetBaseUrl": "/assets/game/arenas/blades-edge-arena",
"bounds": { "min": [-18, 27, -68], "max": [78, 83, 68] },
"teamSpawns": [[29.2, 50.7, -51.49], [31.13, 54.4, 49.51]],
"preparationGates": [[29, 51, -44], [31, 54, 42]],
"teamSpawns": [[29.2, 50.702, -51.49], [31.13, 54.403, 49.51]],
"preparationGates": [[29.067, 50.702, -46.492], [29.137, 54.403, 44.924]],
"spectatorPositions": [[-10, 75, 0], [70, 75, 0]],
"shadowSight": [[10, 55, 0], [50, 55, 0]],
"lineOfSight": [
@@ -44,8 +44,8 @@
"mapId": 572,
"assetBaseUrl": "/assets/game/arenas/ruins-of-lordaeron",
"bounds": { "min": [-62, 26, -108], "max": [54, 37, 78] },
"teamSpawns": [[-8.68, 30.1, -73.96], [-5.08, 27.6, 57.28]],
"preparationGates": [[-8, 30, -65], [-5, 28, 49]],
"teamSpawns": [[-8.68, 30.098, -73.96], [-5.08, 27.601, 57.28]],
"preparationGates": [[-8.302, 30.098, -68.974], [-5.032, 27.601, 52.28]],
"spectatorPositions": [[48, 45, -15], [-55, 45, -15]],
"shadowSight": [[18, 29, -15], [-26, 29, -15]],
"lineOfSight": [
@@ -61,8 +61,8 @@
"mapId": 617,
"assetBaseUrl": "/assets/game/arenas/dalaran-sewers",
"bounds": { "min": [-52, 0, -68], "max": [52, 32, 40] },
"teamSpawns": [[-12.41, 20.1, -63.05], [21.79, 20.2, 25.15]],
"preparationGates": [[-10, 20, -55], [20, 20, 18]],
"teamSpawns": [[-12.41, 20.1, -63.05], [21.79, 20.221, 25.65]],
"preparationGates": [[-9.252, 20.1, -59.173], [22.524, 20.221, 20.704]],
"spectatorPositions": [[-45, 31, -14], [45, 31, -14]],
"shadowSight": [[-18, 20, -14], [18, 20, -14]],
"lineOfSight": [
@@ -81,8 +81,8 @@
"mapId": 618,
"assetBaseUrl": "/assets/game/arenas/ring-of-valor",
"bounds": { "min": [-58, 20, -40], "max": [58, 45, 42] },
"teamSpawns": [[0, 27, 25], [0, 27, -25]],
"preparationGates": [[0, 27, 20], [0, 27, -20]],
"teamSpawns": [[-1.5, 26.8, 26], [0, 26.8, -25]],
"preparationGates": [[-3.413, 26.8, 21.381], [-3.536, 26.8, -21.464]],
"spectatorPositions": [[-52, 50, 0], [52, 50, 0]],
"shadowSight": [[-25, 27, 0], [25, 27, 0]],
"lineOfSight": [
@@ -106,8 +106,8 @@
"mapId": 129,
"assetBaseUrl": "/assets/game/arenas/warnorken-arena",
"bounds": { "min": [-47, -24, -47], "max": [47, 25, 47] },
"teamSpawns": [[-42.7, -21.75, 14.12], [41.3, -21.65, -6.58]],
"preparationGates": [[-35, -21.5, 12], [34, -21.5, -5]],
"teamSpawns": [[-42.7, -21.748, 14.12], [41.3, -21.647, -6.58]],
"preparationGates": [[-37.879, -21.748, 12.793], [40.407, -21.647, -1.66]],
"spectatorPositions": [[0, 19, 42], [0, 19, -42]],
"shadowSight": [[0, -21.5, 20], [0, -21.5, -20]],
"lineOfSight": [
@@ -122,9 +122,9 @@
"sourceMap": "BlackrookHoldArena",
"mapId": 1784,
"assetBaseUrl": "/assets/game/arenas/black-rook-hold-arena",
"bounds": { "min": [-72, -8, -228], "max": [130, 28, -128] },
"teamSpawns": [[-66.098564, 8.416428, -201.852448], [123.158585, 9.916428, -219.909592]],
"preparationGates": [[-42, 8.4, -201], [98, 9.9, -218]],
"bounds": { "min": [-37, -32, -286], "max": [126, 20, -138] },
"teamSpawns": [[-35.043, -28.317, -204.435], [108.031, -28.317, -205.573]],
"preparationGates": [[-30.043, -28.317, -204.435], [103.031, -28.317, -205.573]],
"spectatorPositions": [[25, 25, -150], [25, 25, -224]],
"shadowSight": [[15, 9, -181], [15, 9, -215]],
"lineOfSight": [
@@ -141,8 +141,8 @@
"mapId": 1785,
"assetBaseUrl": "/assets/game/arenas/ashamanes-fall",
"bounds": { "min": [14, -4, 2], "max": [58, 16, 36] },
"teamSpawns": [[18.223099, -0.480263, 32.034554], [55.023094, -0.480263, 7.123436]],
"preparationGates": [[25, -0.5, 27], [48, -0.5, 12]],
"teamSpawns": [[18.723, -0.449, 32.035], [55.023, -0.477, 7.123]],
"preparationGates": [[22.737, -0.449, 29.053], [50.916, -0.477, 9.975]],
"spectatorPositions": [[20, 13, 6], [55, 13, 33]],
"shadowSight": [[29, -0.5, 13], [45, -0.5, 27]],
"lineOfSight": [
+128 -121
View File
@@ -1,6 +1,6 @@
import { Clone, useProgress } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { Component, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { Group, Vector3 } from "three";
import { selectedCharacter, useShellStore } from "./app/shellStore";
import { CharacterModel } from "./avatar/CharacterModel";
@@ -8,10 +8,27 @@ import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
import { classById } from "./app/characterCatalog";
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment";
import { useArenaStore } from "./game/arenaStore";
import { arenaCameraPose } from "./game/arenaCamera";
import { arenaCameraPose, arenaSpawnYaw } from "./game/arenaCamera";
import { ArenaPlayerCamera } from "./scene/ArenaPlayerCamera";
import { ArenaActionBar } from "./ui/ArenaActionBar";
import { controlledArenaActor, arenaTargetActor } from "./game/arenaPlayer";
import { abilityById, abilityAtLevel } from "./game/abilityCatalog";
import type { ActionBindingLayer } from "./game/actionBindings";
import { useArenaInput } from "./game/useArenaInput";
import { beginMouseLookDrag, endMouseLookDrag, isDesktopCameraLookPointer } from "./game/inputManager";
import type { CharacterCombatAnimationEvent } from "./game/combatAnimation";
import { arenaStageById } from "./game/arenaStages";
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
import { ArenaCombatEffects, ArenaGroundTarget } from "./scene/ArenaCombatEffects";
import { ArenaPanels } from "./ui/ArenaPanels";
import { useArenaCombatView } from "./game/useArenaCombatView";
import { useGameStore } from "./game/store";
import { targetingRulesForAbility } from "./game/abilityRules";
import type { Vec3Tuple } from "./game/arenaTypes";
import { ArenaNavigationSurface } from "./scene/ArenaNavigationSurface";
const ArenaFallbackStage = lazy(() => import("./scene/ArenaFallbackStage"));
class ArenaStageErrorBoundary extends Component<{
readonly children: ReactNode;
@@ -37,7 +54,7 @@ function StageEnvironment({ stage, onReady }: { stage: ArenaStageManifest; onRea
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
return (
<>
<ProceduralStage stage={stage} floorOnly />
<ArenaNavigationSurface stageId={stage.id} />
{visual ? <LoadedStage url={visual} onReady={onReady} /> : <StageReady onReady={onReady} />}
</>
);
@@ -56,53 +73,6 @@ function LoadedStage({ url, onReady }: { url: string; onReady: () => void }) {
return <Clone object={gltf.scene} />;
}
function ProceduralStage({ stage, floorOnly = false }: { stage: ArenaStageManifest; floorOnly?: boolean }) {
const width = stage.bounds.max[0] - stage.bounds.min[0];
const depth = stage.bounds.max[2] - stage.bounds.min[2];
const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2;
const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2;
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1])) - 0.175;
const wallHeight = 2.4;
return (
<group>
<mesh position={[centerX, floorY, centerZ]} receiveShadow>
<boxGeometry args={[width, 0.25, depth]} />
<meshStandardMaterial color={floorOnly ? "#364038" : "#3d4d43"} roughness={0.92} />
</mesh>
{!floorOnly && (
<>
<gridHelper args={[Math.max(width, depth), 24, "#c8aa5c", "#596c5f"]} position={[centerX, floorY + 0.14, centerZ]} />
{[
[centerX, floorY + wallHeight / 2, stage.bounds.min[2], width, wallHeight, 0.6],
[centerX, floorY + wallHeight / 2, stage.bounds.max[2], width, wallHeight, 0.6],
[stage.bounds.min[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth],
[stage.bounds.max[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth],
].map(([x, y, z, sizeX, sizeY, sizeZ], index) => (
<mesh key={`boundary-${index}`} position={[x, y, z]} receiveShadow>
<boxGeometry args={[sizeX, sizeY, sizeZ]} />
<meshStandardMaterial color="#26342d" roughness={0.88} />
</mesh>
))}
{stage.teamSpawns.map((spawn, index) => (
<mesh key={`spawn-${index}`} position={[spawn[0], floorY + 0.2, spawn[2]]} receiveShadow>
<cylinderGeometry args={[3.4, 3.4, 0.18, 32]} />
<meshStandardMaterial color={index === 0 ? "#338dcc" : "#bd4944"} emissive={index === 0 ? "#123d5b" : "#541c1a"} emissiveIntensity={0.7} />
</mesh>
))}
{stage.lineOfSight.map((object) => (
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
{object.shape === "cylinder"
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
: <boxGeometry args={object.size as [number, number, number]} />}
<meshStandardMaterial color="#6a6f5e" roughness={0.78} />
</mesh>
))}
</>
)}
</group>
);
}
function DynamicStageObjects({ stage, objects }: { stage: ArenaStageManifest; objects: readonly ArenaDynamicObjectSnapshot[] }) {
const states = useMemo(() => new Map(objects.map((object) => [object.id, object])), [objects]);
const definitions = useMemo(() => new Map(stage.dynamicObjects.map((object) => [object.id, object])), [stage]);
@@ -154,17 +124,21 @@ function DynamicStageObjects({ stage, objects }: { stage: ArenaStageManifest; ob
);
}
function ArenaActor({ actor, local, character, movement, stage }: { actor: ArenaActorSnapshot; local: boolean; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; stage: ArenaStageManifest }) {
const group = useRef<Group>(null);
function ArenaActor({ actor, local, character, player }: { actor: ArenaActorSnapshot; local: boolean; character: ReturnType<typeof selectedCharacter>; player: RefObject<Group | null> }) {
const other = useRef<Group>(null);
const group = local ? player : other;
const initial = useRef<[number, number, number]>([...actor.position]);
const target = useMemo(() => new Vector3(), []);
const movingRef = useRef(false);
const animationEventRef = useRef<CharacterCombatAnimationEvent | null>(null);
useFrame((_, delta) => {
const node = group.current;
if (!node) return;
if (local) {
node.position.x = Math.max(stage.bounds.min[0], Math.min(stage.bounds.max[0], node.position.x + movement.current[0] * 7 * delta));
node.position.z = Math.max(stage.bounds.min[2], Math.min(stage.bounds.max[2], node.position.z + movement.current[1] * 7 * delta));
}
node.position.lerp(new Vector3(...actor.position), Math.min(1, delta * (local ? 4 : 14)));
node.rotation.y = actor.facing;
target.set(...actor.position);
movingRef.current = node.position.distanceToSquared(target) > 0.0025;
node.position.lerp(target, Math.min(1, delta * 18));
if (Number.isFinite(actor.facing)) node.rotation.y = actor.facing;
animationEventRef.current = actor.combat?.playerAnimationEvent ?? null;
});
const color = actor.team === 0 ? "#58b9ff" : "#e45b55";
const cosmeticEquipment = useMemo(
@@ -172,9 +146,10 @@ function ArenaActor({ actor, local, character, movement, stage }: { actor: Arena
[character],
);
return (
<group ref={group} position={actor.position as [number, number, number]} rotation={[0, actor.facing, 0]} visible={actor.alive}>
{local && character ? (
<CharacterModel identity={character} equipment={cosmeticEquipment} fallback={<ProceduralAvatar accent={classById(character.classId).color} />} />
<group ref={group} name={`arena-actor:${actor.id}`} position={initial.current} visible={actor.alive}
onPointerDown={(event) => { if (event.button === 0) useArenaStore.getState().intent({ type: "target", targetActorId: actor.id }); }}>
{(local && character) || actor.avatar ? (
<CharacterModel identity={local && character ? character : actor.avatar} equipment={local ? cosmeticEquipment : actor.visualEquipment} movingRef={movingRef} animationEventRef={animationEventRef} fallback={<ProceduralAvatar accent={color} />} />
) : (
<group>
<mesh position={[0, 0.9, 0]} castShadow><capsuleGeometry args={[0.45, 1, 6, 12]} /><meshStandardMaterial color={color} roughness={0.55} /></mesh>
@@ -186,26 +161,16 @@ function ArenaActor({ actor, local, character, movement, stage }: { actor: Arena
);
}
function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) {
const target = useMemo(() => new Vector3(), []);
const desired = useMemo(() => new Vector3(), []);
const pose = useMemo(() => arenaCameraPose(stage, actor?.team ?? 0), [actor?.team, stage]);
useFrame(({ camera }, delta) => {
if (!actor) return;
target.set(...pose.target);
desired.set(...pose.position);
camera.position.lerp(desired, Math.min(1, delta * 5));
camera.lookAt(target);
});
return null;
}
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, detailedAsset, onStageReady, onStageError }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; detailedAsset: boolean; onStageReady: () => void; onStageError: (error: unknown) => void }) {
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, detailedAsset, blocked, onStageReady, onStageError, groundTarget, groundPosition, controller }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; detailedAsset: boolean; blocked: boolean; onStageReady: () => void; onStageError: (error: unknown) => void; groundTarget: string | null; groundPosition: RefObject<Vec3Tuple>; controller: boolean }) {
const snapshot = useArenaStore((state) => state.snapshot);
const player = useRef<Group>(null);
const environment = useRef<Group>(null);
const initialCamera = useMemo(() => arenaCameraPose(localActor?.position ?? stage.teamSpawns[0], arenaSpawnYaw(stage, localActor?.team ?? 0)).position, [stage]);
return (
<Canvas
shadows
dpr={[1, 1.35]}
camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}
camera={{ fov: 62, near: 0.05, far: 1200, position: initialCamera }}
fallback={<div className="arena-render-fallback"><strong>3D arena unavailable</strong><small>This device could not start WebGL. Return to Arena Command and try another renderer.</small></div>}
>
<color attach="background" args={["#111815"]} />
@@ -213,14 +178,19 @@ function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, move
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
<GameGltfLoaderLifecycle>
<group ref={environment}>
{detailedAsset ? (
<ArenaStageErrorBoundary key={stage.id} fallback={null} onError={onStageError}>
<Suspense fallback={<ProceduralStage stage={stage} />}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
<Suspense fallback={<Suspense fallback={null}><ArenaFallbackStage stageId={stage.id} /></Suspense>}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
</ArenaStageErrorBoundary>
) : <ProceduralStage stage={stage} />}
) : <Suspense fallback={null}><ArenaFallbackStage stageId={stage.id} /></Suspense>}
<DynamicStageObjects stage={stage} objects={dynamicObjects} />
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} movement={movement} stage={stage} />)}
<ArenaCamera actor={localActor} stage={stage} />
</group>
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} player={player} />)}
<ArenaPlayerCamera key={`${stage.id}:${localActor?.id}`} actor={localActor} stage={stage} player={player} environment={environment} movement={movement} blocked={blocked} />
{snapshot && <ArenaCombatEffects snapshot={snapshot} actorId={localActor?.id} />}
{groundTarget && localActor && <ArenaGroundTarget environment={environment} position={groundPosition} origin={localActor.position}
radius={abilityById(groundTarget)?.radius ?? 8} controller={controller} />}
</GameGltfLoaderLifecycle>
</Canvas>
);
@@ -246,13 +216,16 @@ function ArenaAssetStatus({ stage, ready, fallbackReason }: { readonly stage: Ar
}
function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
const snapshot = useArenaStore((state) => state.snapshot);
const selected = snapshot?.actors.find((actor) => actor.id === snapshot.controlledActorId)?.targetActorId;
return (
<div className={`arena-team-frames arena-team-frames--${team}`}>
{actors.filter((actor) => actor.team === team).map((actor) => (
<button key={actor.id} type="button" className={!actor.alive ? "is-dead" : ""} onClick={() => useArenaStore.getState().intent({ type: "target", targetActorId: actor.id })}>
<button key={actor.id} type="button" className={`${!actor.alive ? "is-dead" : ""} ${selected === actor.id ? "is-selected" : ""}`} onClick={() => useArenaStore.getState().intent({ type: "target", targetActorId: actor.id })}>
<span><strong>{actor.name}</strong><small>{actor.isBot ? "AI" : "Player"} / {actor.role}</small></span>
<i><b style={{ width: `${actor.health / actor.maxHealth * 100}%` }} /></i>
<em>{actor.health}</em>
<em>{Math.ceil(actor.health)} / {actor.maxHealth}</em>
{actor.combat?.activeCast && <small>{abilityById(actor.combat.activeCast.abilityId)?.name ?? "Casting"}</small>}
</button>
))}
</div>
@@ -269,7 +242,20 @@ export default function ArenaRuntime() {
const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter);
const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null;
const localActor = controlledArenaActor(snapshot, character);
const feedback = useArenaStore((state) => state.error);
const overlay = useGameStore((state) => state.overlay);
const companionOpen = useGameStore((state) => state.companionOpen);
const paused = overlay !== null || companionOpen;
const setPaused = (value: boolean | ((previous: boolean) => boolean)) => {
const next = typeof value === "function" ? value(paused) : value;
if (next) useGameStore.getState().openOverlay("pause"); else useGameStore.getState().closeOverlay();
};
const [groundTarget, setGroundTarget] = useState<string | null>(null);
const groundPosition = useRef<Vec3Tuple>([0, 0, 0]);
useArenaCombatView(character, localActor);
const [actionLayer, setActionLayer] = useState<ActionBindingLayer>("primary");
const [inputMode, setInputMode] = useState<"keyboard" | "gamepad">("keyboard");
const movementRef = useRef<[number, number]>([0, 0]);
const [loadedStageId, setLoadedStageId] = useState<string | null>(null);
const [stageFailure, setStageFailure] = useState<{ readonly stageId: string; readonly reason: string } | null>(null);
@@ -308,38 +294,38 @@ export default function ArenaRuntime() {
});
}, [character, load, session, snapshot]);
useEffect(() => {
const pressed = new Set<string>();
const movement = () => {
const next: [number, number] = [
Number(pressed.has("KeyD") || pressed.has("ArrowRight")) - Number(pressed.has("KeyA") || pressed.has("ArrowLeft")),
Number(pressed.has("KeyS") || pressed.has("ArrowDown")) - Number(pressed.has("KeyW") || pressed.has("ArrowUp")),
];
movementRef.current = next;
intent({ type: "movement", movement: { x: next[0], z: next[1] } });
};
const down = (event: KeyboardEvent) => {
if (["KeyW", "KeyA", "KeyS", "KeyD", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code)) {
pressed.add(event.code);
movement();
}
if (event.code === "Digit1") intent({ type: "ability", abilityId: "attack" });
if (event.code === "Digit2") intent({ type: "ability", abilityId: "strike" });
if (event.code === "Digit3") intent({ type: "ability", abilityId: "interrupt" });
if (event.code === "Digit4") intent({ type: "ability", abilityId: "control" });
if (event.code === "Digit5") intent({ type: "ability", abilityId: "defensive" });
if (event.code === "Digit6") intent({ type: "ability", abilityId: "heal" });
};
const up = (event: KeyboardEvent) => {
if (pressed.delete(event.code)) movement();
};
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
};
}, [intent]);
const cast = useCallback((abilityId: string) => {
const state = useArenaStore.getState();
const player = controlledArenaActor(state.snapshot, character);
if (!player || useGameStore.getState().overlay || state.result) return;
const definition = abilityById(abilityId);
const ability = definition ? abilityAtLevel(definition, player.level ?? character?.level ?? 80) : null;
if (ability && targetingRulesForAbility(ability).shape === "ground" && (targetingRulesForAbility(ability).groundRange ?? 0) > 0) {
groundPosition.current = [player.position[0] + Math.sin(player.facing) * 8, player.position[1], player.position[2] + Math.cos(player.facing) * 8];
setGroundTarget(abilityId);
return;
}
let target = arenaTargetActor(state.snapshot, player.targetActorId) ?? undefined;
if (target && !target.alive && !ability?.effects.some((effect) => effect.kind === "resurrection")) target = undefined;
if (ability?.target === "self" || (ability?.target === "friendly" && target?.team !== player.team)) target = player;
if (ability?.target === "hostile" && (!target || target.team === player.team)) {
target = state.snapshot?.actors.filter((actor) => actor.alive && actor.team !== player.team).sort((a, b) => (
Math.hypot(a.position[0] - player.position[0], a.position[2] - player.position[2])
- Math.hypot(b.position[0] - player.position[0], b.position[2] - player.position[2])
))[0];
}
if (target && target.id !== player.id) state.intent({ type: "target", targetActorId: target.id });
state.intent({ type: "ability", abilityId, targetActorId: target?.id });
}, [character]);
const confirmGroundTarget = () => {
if (!groundTarget) return;
intent({ type: "ability", abilityId: groundTarget, groundPosition: groundPosition.current });
setGroundTarget(null);
};
useArenaInput({ character, blocked: paused || Boolean(result) || !localActor?.alive,
cast, togglePause: () => setPaused((value) => !value), setLayer: setActionLayer, setInputMode,
groundTargetActive: () => Boolean(groundTarget), confirmGroundTarget,
cancelGroundTarget: () => { setGroundTarget(null); intent({ type: "command", command: "cancel-cast" }); } });
const exit = async () => {
await leave(session);
@@ -351,19 +337,40 @@ export default function ArenaRuntime() {
const detailedAsset = stageFailure?.stageId !== stage.id;
return (
<main className="arena-runtime">
<div className="arena-world" key={`${stage.id}:${detailedAsset ? "detailed" : "fallback"}`}><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} detailedAsset={detailedAsset} onStageReady={handleStageReady} onStageError={handleStageError} /></div>
<div className="arena-world" aria-label="Arena 3D view" key={`${stage.id}:${detailedAsset ? "detailed" : "fallback"}`}
onPointerDown={(event) => {
if (event.button === 0 && groundTarget && !paused) { confirmGroundTarget(); return; }
if (paused || result || !isDesktopCameraLookPointer(event.pointerType, event.button)) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
beginMouseLookDrag(event.clientX, event.clientY);
}}
onPointerUp={(event) => { if (event.button === 2) endMouseLookDrag(); }}
onLostPointerCapture={() => endMouseLookDrag()}
onContextMenu={(event) => event.preventDefault()}><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} detailedAsset={detailedAsset} blocked={paused || Boolean(result)} onStageReady={handleStageReady} onStageError={handleStageError} groundTarget={groundTarget} groundPosition={groundPosition} controller={inputMode === "gamepad"} /></div>
<ArenaAssetStatus stage={stage} ready={loadedStageId === stage.id} fallbackReason={stageFailure?.stageId === stage.id ? stageFailure.reason : null} />
<header className="arena-match-header">
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
</header>
<TeamFrames actors={snapshot.actors} team={0} />
<TeamFrames actors={snapshot.actors} team={1} />
<div className="arena-combat-log" role="log" aria-label="Arena combat events">{(snapshot.combatEvents ?? []).filter((event) => snapshot.serverTime - event.occurredAt < 3000 && (event.sourceActorId === localActor?.id || event.targetActorId === localActor?.id) && ["damage", "healing", "interrupt"].includes(event.kind)).slice(-4).map((event) => <div key={event.id}>{abilityById(event.abilityId ?? "")?.name ?? event.kind}: {event.outcome === "miss" ? "Miss" : Math.round(event.effectiveAmount)} {event.absorbed > 0 ? `(${event.absorbed} absorbed)` : ""}</div>)}</div>
{snapshot.phase === "preparation" && <div className="arena-countdown"><small>Gates sealed</small><strong>{seconds}</strong><span>Prepare your opening</span></div>}
<nav className="arena-action-bar" aria-label="Arena abilities">
{["attack", "strike", "interrupt", "control", "defensive", "heal"].map((ability, index) => (
<button key={ability} type="button" onClick={() => intent({ type: "ability", abilityId: ability })}><b>{index + 1}</b><span>{ability}</span></button>
))}
{character && <ArenaActionBar character={character} actor={localActor} layer={actionLayer} inputMode={inputMode}
blocked={paused || Boolean(result)} onCast={cast} feedback={feedback} />}
{groundTarget && <div className="arena-ground-hint" role="status">Place {abilityById(groundTarget)?.name} · Click or confirm to cast · Escape to cancel</div>}
{character && <ArenaPanels character={character} actor={localActor} snapshot={snapshot} stage={stage} cast={cast} />}
<nav className="arena-toolbar" aria-label="Arena character menus">
{(["spellbook", "inventory", "talents", "bindings", "keybinds", "map", "options"] as const).map((panel) => <button key={panel} type="button" onClick={() => useGameStore.getState().toggleOverlay(panel)}>{panel === "inventory" ? "Character" : panel}</button>)}
</nav>
{overlay === "pause" && !result && <section className="arena-result-panel" role="dialog" aria-label="Arena menu">
<h1>Arena menu</h1><p>Your character stays in the match while this menu is open.</p>
<button type="button" className="front-button" onClick={() => setPaused(false)}>Resume</button>
<button type="button" className="front-button" onClick={() => useGameStore.getState().openOverlay("options")}>Options</button>
<button type="button" className="front-button" onClick={() => useGameStore.getState().openOverlay("keybinds")}>Keybinds</button>
<button type="button" className="front-button" onClick={() => useGameStore.getState().openOverlay("bindings")}>Assign abilities</button>
<button type="button" className="front-button" onClick={() => void exit()}>Leave arena</button>
</section>}
<button className="arena-exit" type="button" onClick={() => void exit()}>{result ? "Leave arena" : "Exit / bot takeover"}</button>
{result && (
<section className="arena-result-panel" role="dialog" aria-modal="true">
+17 -10
View File
@@ -30,7 +30,9 @@ import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
import { UiLayoutEditor } from "./ui/UiLayoutEditor";
import { EditableHudElement } from "./ui/EditableHudElement";
import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
import { actionKeyForEvent, desktopSlotForKey } from "./game/uiLayout";
import { useUiLayoutStore } from "./game/uiLayoutStore";
import { profileInputBindings } from "./game/keybindPreferences";
import { KeybindsPanel } from "./ui/KeybindsPanel";
function InputBridge() {
const overlay = useGameStore((state) => state.overlay);
@@ -38,16 +40,18 @@ function InputBridge() {
useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
handleCustomKey: (event) => {
const game = useGameStore.getState();
if (game.overlay === "ui-layout") return !["Tab", "Enter", "Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code);
if (currentUiDevice() !== "pc" || game.overlay || game.companionOpen || useCombatStore.getState().pendingGroundTarget) return false;
const button = desktopSlotForKey(activeUiLayout(), actionKeyForEvent(event));
if (!button) return false;
if (!event.repeat && button.abilityId) useCombatStore.getState().castAbility(button.abilityId, game.playerPosition, Date.now());
game.setInputMode("keyboard");
return true;
inputSuspended: () => ["ui-layout", "keybinds"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences;
const bindings = profileInputBindings(preferences, device);
for (let b = activeUiLayout().barCount; b < 5; b++) for (let s = 0; s < 12; s++) bindings[`bar:${b}:${s}`] = null;
return bindings;
},
castDesktopSlot: (bar, slot) => {
const game = useGameStore.getState(); const button = activeUiLayout().bars[bar]?.slots[slot];
if (button?.abilityId) useCombatStore.getState().castAbility(button.abilityId, game.playerPosition, Date.now());
},
togglePanel: (panel) => useGameStore.getState().toggleOverlay(panel),
togglePause: () => useGameStore.getState().togglePause(),
toggleMap: () => useGameStore.getState().toggleMap(),
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
@@ -93,6 +97,7 @@ function InputBridge() {
}
export default function GameRuntime() {
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
useEffect(() => () => {
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
clearDungeonRuntimeCaches();
@@ -102,6 +107,7 @@ export default function GameRuntime() {
return (
<DualDisplayFrame
contextLabel="Tactical"
companionDisabled={editingControls}
onActiveSurfaceChange={(surface) => useGameStore.getState().setCompanionOpen(surface === "bottom")}
top={
<main className="game-shell">
@@ -119,6 +125,7 @@ export default function GameRuntime() {
<GameplayPanels />
<PauseMenu />
<UiLayoutEditor />
<KeybindsPanel />
<GmReviewCard />
<GmWorldEditor />
</main>
+35 -4
View File
@@ -212,7 +212,7 @@ describe("local profile repository", () => {
}));
expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(5);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6);
}
const legacyV3Storage = new MemoryStorage();
@@ -231,13 +231,13 @@ describe("local profile repository", () => {
},
}));
expect(loadCombatPresentationSettings("offline-roster", legacyV3Storage)).toMatchObject({
vfxMode: "generated",
vfxMode: "original",
vfxQuality: "low",
});
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
.presentationSettingsByOwner["offline-roster"].vfxMode).toBe("generated");
.presentationSettingsByOwner["offline-roster"].vfxMode).toBe("original");
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
.presentationSettingsByOwner["offline-roster"].originalMediaEnabled).toBe(false);
.presentationSettingsByOwner["offline-roster"].originalMediaEnabled).toBe(true);
const storage = new MemoryStorage();
const firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId;
@@ -258,6 +258,37 @@ describe("local profile repository", () => {
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
});
it("migrates the previous media default once while preserving combat controls", () => {
const storage = new MemoryStorage();
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
version: 5,
accounts: [],
rosters: { "offline-roster": [] },
presentationSettingsByOwner: {
"offline-roster": {
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: false,
vfxMode: "generated",
vfxQuality: "low",
audioEnabled: { player: true, party: false, enemy: true },
},
},
}));
expect(loadCombatPresentationSettings("offline-roster", storage)).toMatchObject({
originalMediaEnabled: true,
vfxMode: "original",
vfxQuality: "low",
audioEnabled: { player: true, party: false, enemy: true },
});
updateCombatPresentationSettings("offline-roster", {
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: false, vfxMode: "generated",
}, storage);
expect(loadCombatPresentationSettings("offline-roster", storage)).toMatchObject({
originalMediaEnabled: false, vfxMode: "generated",
});
});
it("migrates custom-class profiles into the CoA category", () => {
const storage = new MemoryStorage();
const offline = createOfflineSession();
+15 -6
View File
@@ -61,7 +61,7 @@ interface LocalAccount {
}
interface ProfileDatabase {
version: 5;
version: 6;
accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
@@ -104,7 +104,7 @@ function browserSessionStorage(): StorageLike | null {
}
function emptyDatabase(): ProfileDatabase {
return { version: 5, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
return { version: 6, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
}
function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -206,7 +206,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
presentationSettingsByOwner?: unknown;
uiPreferencesByOwner?: unknown;
}) | null;
if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
if (!parsed || ![1, 2, 3, 4, 5, 6].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase();
}
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
@@ -232,13 +232,22 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
let presentationSettingsMigrated = false;
const presentationSettingsByOwner = Object.fromEntries(
Object.entries(rawPresentationSettings).map(([ownerId, settings]) => {
const normalized = normalizeCombatPresentationSettings(settings);
const storedMode = settings && typeof settings === "object" && !Array.isArray(settings)
? (settings as { vfxMode?: unknown }).vfxMode
: undefined;
const storedOriginalMediaEnabled = settings && typeof settings === "object" && !Array.isArray(settings)
? (settings as { originalMediaEnabled?: unknown }).originalMediaEnabled
: undefined;
// Move saves using the former generated-only default to the new default
// once, retaining their quality, visibility, mute and volume preferences.
const migrateMediaDefault = (parsed.version ?? 0) < 6
&& storedMode === "generated"
&& storedOriginalMediaEnabled !== true;
const normalized = normalizeCombatPresentationSettings(migrateMediaDefault ? {
...(settings as Record<string, unknown>),
originalMediaEnabled: true,
vfxMode: "original",
} : settings);
if (
storedMode !== normalized.vfxMode
|| storedOriginalMediaEnabled !== normalized.originalMediaEnabled
@@ -248,8 +257,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
);
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 5 || presentationSettingsMigrated) writeDatabase(database, storage);
const database: ProfileDatabase = { version: 6, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 6 || presentationSettingsMigrated) writeDatabase(database, storage);
return database;
} catch {
return emptyDatabase();
+1 -1
View File
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
},
});
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(5);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6);
});
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
+7
View File
@@ -13,9 +13,13 @@ describe("profile UI persistence", () => {
it("isolates owners and persists templates without requiring a character", () => {
const local = storage(); const ui = normalizeUiPreferences(null);
ui.slots.pc[1] = { name: "Healer", layout: ui.current.pc };
ui.keybinds.pc["move-forward"] = "ArrowUp";
ui.keybinds.controller.jump = "Button4&Button3";
saveUiPreferences("one", ui, local);
expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer");
expect(loadUiPreferences("two", local).slots.pc[1]).toBeNull();
expect(loadUiPreferences("one", local).keybinds).toEqual(ui.keybinds);
expect(loadUiPreferences("two", local).keybinds.pc["move-forward"]).toBe("KeyW");
ui.slots.pc[1].name = "Unsaved change";
expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer");
});
@@ -34,6 +38,9 @@ describe("profile UI persistence", () => {
const ui = normalizeUiPreferences(null);
ui.slots.pc[2] = { name: "Desktop raid", layout: ui.current.pc };
ui.slots.mobile[1] = { name: "On the go", layout: ui.current.mobile };
ui.keybinds.pc["move-forward"] = "ArrowUp";
ui.keybinds.pc["look-up"] = null;
ui.keybinds.controller.jump = "Button4&Button3";
saveUiPreferences(session.ownerId, ui);
await vi.advanceTimersByTimeAsync(501);
expect(client.useCloudSaveStatus.getState().state).toBe("synced");
+19 -13
View File
@@ -1,5 +1,9 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { CONTROLLER_SYSTEM_BACK_TOKEN, subscribeControllerToken } from "../input/controller";
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
import { isControllerMenuBackToken } from "../input/useMenuController";
import { controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { resolveDisplayLayout } from "../platform/displayLayout";
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
@@ -8,11 +12,13 @@ interface DualDisplayFrameProps {
bottom: ReactNode;
contextLabel?: string;
onActiveSurfaceChange?: (surface: DisplaySurface) => void;
companionDisabled?: boolean;
}
const FOCUSABLE = "button:not([disabled]):not([tabindex='-1']), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex='-1'])";
export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onActiveSurfaceChange }: DualDisplayFrameProps) {
export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onActiveSurfaceChange, companionDisabled = false }: DualDisplayFrameProps) {
const bindingLabel = useInputBindingLabels();
const params = new URLSearchParams(window.location.search);
const dedicatedSurface = params.get("display");
const layout = resolveDisplayLayout({ display: dedicatedSurface, layout: params.get("layout") });
@@ -38,17 +44,17 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
requestDisplaySurface("top");
const unsubscribeSurface = subscribeDisplaySurface(showSurface);
const unsubscribeController = subscribeControllerToken(({ token, repeat, pressed }) => {
if (pressed === false) return;
if (token === "Button8" && !repeat) toggleSurface();
if ((token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN) && !repeat && activeRef.current === "bottom") {
if (pressed === false || isCapturingInput() || companionDisabled) return;
if (controllerBindingMatches(getInputPreferences().controller.companion, token, getControllerHeldTokens()) && !repeat) toggleSurface();
if (isControllerMenuBackToken(token) && !repeat && activeRef.current === "bottom") {
requestDisplaySurface("top");
}
});
const onKeyDown = (event: KeyboardEvent) => {
if (event.repeat || event.defaultPrevented) return;
if (event.repeat || event.defaultPrevented || isCapturingInput() || companionDisabled) return;
const target = event.target as HTMLElement | null;
const isEditable = target?.matches("input, textarea, select, [contenteditable='true']") ?? false;
if (event.code === "KeyC" && !isEditable) {
if (inputKeyForEvent(event) && inputKeyForEvent(event) === getInputPreferences().pc.companion && !isEditable) {
event.preventDefault();
toggleSurface();
}
@@ -61,7 +67,7 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
onSurfaceChangeRef.current?.("top");
requestDisplaySurface("top");
};
}, [dedicatedSurface, layout, showSurface, toggleSurface]);
}, [companionDisabled, dedicatedSurface, layout, showSurface, toggleSurface]);
useEffect(() => {
if (layout !== "single" || activeSurface !== "bottom") return;
@@ -73,8 +79,8 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
});
const onModalKeyDown = (event: KeyboardEvent) => {
const dialog = dialogRef.current;
if (!dialog) return;
if (event.key === "Escape") {
if (!dialog || isCapturingInput() || event.defaultPrevented) return;
if (inputKeyForEvent(event) && [getInputPreferences().pc["menu-back"], getInputPreferences().pc["menu-back-alt"]].includes(inputKeyForEvent(event))) {
event.preventDefault();
requestDisplaySurface("top");
return;
@@ -122,9 +128,9 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
<div className="single-context-surface">{bottom}</div>
</div>
)}
<button className="single-context-toggle" type="button" onClick={toggleSurface} aria-expanded={contextOpen}>
<b>{contextOpen ? "Return" : contextLabel}</b><small>Select / C</small>
</button>
{!companionDisabled && <button className="single-context-toggle" type="button" onClick={toggleSurface} aria-expanded={contextOpen}>
<b>{contextOpen ? "Return" : contextLabel}</b><small>{bindingLabel("controller", "companion")} / {bindingLabel("pc", "companion")}</small>
</button>}
</div>
);
}
+39 -6
View File
@@ -1,4 +1,5 @@
import { readFileSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { runInNewContext } from "node:vm";
import { describe, expect, it, vi } from "vitest";
import { ARENA_STAGES } from "../game/arenaStages";
@@ -9,10 +10,11 @@ const workerSource = readFileSync(
"utf8",
);
function contentWorker(cached?: Response) {
function contentWorker(cached?: Response, bundledFiles: Record<string, unknown> = {}, object?: Response) {
const handlers = new Map<string, (event: unknown) => void>();
const fetch = vi.fn(async () => new Response("bundled arena", { status: 200 }));
const match = vi.fn(async () => cached);
const fetch = vi.fn(async (url: RequestInfo | URL) => String(url) === "/content/bootstrap-manifest.json"
? Response.json({ files: bundledFiles }) : object?.clone() ?? new Response("bundled arena", { status: 200 }));
const match = vi.fn(async (url: RequestInfo | URL) => String(url).includes("/content/cache-objects/") ? object?.clone() : cached?.clone());
runInNewContext(workerSource, {
self: {
location: { origin: "https://healer.test" },
@@ -22,10 +24,13 @@ function contentWorker(cached?: Response) {
fetch,
URL,
Response,
Headers,
DecompressionStream,
});
return {
fetch,
match,
refresh: () => handlers.get("message")!({ data: { type: "refresh-content-bootstrap" } }),
request: (path: string) => {
let response: Promise<Response> | undefined;
handlers.get("fetch")!({
@@ -45,7 +50,7 @@ describe("browser content cache worker", () => {
expect(response?.status).toBe(200);
expect(await response?.text()).toBe("bundled arena");
}
expect(worker.fetch).toHaveBeenCalledTimes(stage.assets.length);
expect(worker.fetch).toHaveBeenCalledTimes(stage.assets.length + 1);
});
it("prefers installed arena updates over bundled files, ignoring the runtime query", async () => {
@@ -61,7 +66,8 @@ describe("browser content cache worker", () => {
const response = await worker.request("/assets/game/manastorm/optional/visual.glb");
expect(response?.status).toBe(404);
expect(await response?.text()).toBe("Optional content is not installed.");
expect(worker.fetch).not.toHaveBeenCalled();
expect(worker.fetch).toHaveBeenCalledTimes(1);
expect(worker.fetch).toHaveBeenCalledWith("/content/bootstrap-manifest.json", { cache: "no-cache" });
});
it("serves only cached same-origin asset requests from the active content cache", () => {
@@ -70,11 +76,38 @@ describe("browser content cache worker", () => {
expect(workerSource).toContain("url.origin !== self.location.origin");
expect(workerSource).toContain("MANAGED_ASSET_PREFIXES.some");
expect(workerSource).toContain('"/assets/game/"');
expect(workerSource).toContain("if (cached) return cached");
expect(workerSource).toContain("if (!objectPath) return cached");
expect(workerSource).toContain("BUNDLED_ASSET_PATHS.has(url.pathname)");
expect(workerSource).toContain("BUNDLED_ASSET_PREFIXES.some");
expect(workerSource).not.toContain('"/assets/characters/wow335a-v1/textures/character/human/",');
expect(workerSource).toContain('status: 404');
expect(workerSource).not.toContain("cached ?? fetch(request)");
});
it("expands bundled gzip without requiring a Manastorm install", async () => {
const url = "/assets/game/manastorm/shared/visual.glb";
const original = "glTF original dungeon".repeat(100);
const worker = contentWorker(undefined, { [url]: { download: { url: "/content/bundled/object.glb.gz", encoding: "gzip" } } }, new Response(gzipSync(original)));
const response = await worker.request(url);
expect(await response?.text()).toBe(original);
expect(response?.headers.get("content-type")).toBe("model/gltf-binary");
});
it("resolves a tiny installed alias to one compressed shared object", async () => {
const original = "glTF installed original".repeat(100);
const worker = contentWorker(new Response(null, { headers: { "x-healer-man-content-object": "/content/cache-objects/shared.glb" } }), {},
new Response(gzipSync(original), { headers: { "x-healer-man-content-encoding": "gzip" } }));
expect(await (await worker.request("/assets/creatures/optional/model.glb"))?.text()).toBe(original);
expect(worker.fetch).not.toHaveBeenCalled();
});
it("refreshes bootstrap mappings when a new app session starts", async () => {
const path = "/assets/game/dungeons/new/visual.glb";
const files: Record<string, unknown> = {};
const worker = contentWorker(undefined, files, new Response(gzipSync("new original")));
expect((await worker.request(path))?.status).toBe(404);
files[path] = { download: { url: "/content/bundled/new.glb.gzip", encoding: "gzip" } };
worker.refresh();
expect(await (await worker.request(path))?.text()).toBe("new original");
});
});
+64 -3
View File
@@ -1,8 +1,10 @@
import { createHash } from "node:crypto";
import { gzipSync, gunzipSync } from "node:zlib";
import { Capacitor } from "@capacitor/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
BROWSER_CONTENT_CACHE_NAME,
browserContentObjectPath,
checkForContentUpdates,
contentPackHasUpdate,
contentPackIsBundled,
@@ -120,7 +122,8 @@ describe("browser content packs", () => {
error: "",
});
const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME);
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("first-model");
expect(await (await active?.match(browserContentObjectPath(manifest.files["/assets/characters/test.glb"])))?.text()).toBe("first-model");
const originalObject = browserContentObjectPath(manifest.files["/assets/characters/test.glb"]);
manifest = contentManifest("content-two", "updated.glb", updatedBytes);
await checkForContentUpdates();
@@ -129,12 +132,13 @@ describe("browser content packs", () => {
corruptUpdate = true;
await expect(installContentPack("characters:test")).rejects.toThrow(/verification/i);
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("first-model");
expect(await (await active?.match(originalObject))?.text()).toBe("first-model");
corruptUpdate = false;
await installContentPack("characters:test");
expect(contentPackHasUpdate("characters:test")).toBe(false);
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("updated-model");
expect(await (await active?.match(browserContentObjectPath(manifest.files["/assets/characters/test.glb"])))?.text()).toBe("updated-model");
expect(await active?.match(originalObject)).toBeUndefined();
await removeContentPack("characters:test");
expect(contentPackIsInstalled("characters:test")).toBe(false);
@@ -151,6 +155,63 @@ describe("browser content packs", () => {
expect(contentPackIsUsable("core:ui")).toBe(true);
});
it("stores a compressed duplicate once and retains it until its last pack is removed", async () => {
const original = new TextEncoder().encode("original shared geometry ".repeat(200));
const compressed = gzipSync(original);
const firstPath = "/assets/characters/shared-first.glb";
const secondPath = "/assets/characters/shared-second.glb";
const thirdPath = "/assets/characters/shared-third.glb";
const base = contentManifest("compressed", "original.glb", original);
const entry = { ...base.files["/assets/characters/test.glb"], download: {
url: "/content/objects/shared.glb.gz", encoding: "gzip" as const, sha256: sha256(compressed), bytes: compressed.length,
} };
const manifest: ContentManifest = { ...base, packs: [
{ ...base.packs[0], id: "characters:shared-first", fileCount: 2 },
{ ...base.packs[0], id: "characters:shared-second" },
], files: {
[firstPath]: { ...entry, packId: "characters:shared-first" },
[secondPath]: { ...entry, packId: "characters:shared-first" },
[thirdPath]: { ...entry, packId: "characters:shared-second" },
} };
const storage = new MemoryCacheStorage();
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
vi.stubGlobal("caches", storage as unknown as CacheStorage);
const fetcher = vi.fn(async (url: RequestInfo | URL) => String(url).endsWith("manifest.json") ? Response.json(manifest) : new Response(compressed));
vi.stubGlobal("fetch", fetcher);
await checkForContentUpdates();
await installContentPack("characters:shared-first");
const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME)!;
expect(active.responses.size).toBe(3);
const objectPath = browserContentObjectPath(entry);
expect(gunzipSync(Buffer.from(await (await active.match(objectPath))!.arrayBuffer()))).toEqual(Buffer.from(original));
expect(getContentManagerSnapshot().totalBytes).toBe(compressed.length);
await installContentPack("characters:shared-second");
expect(getContentManagerSnapshot().totalBytes).toBe(0);
expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith(".gz"))).toHaveLength(1);
await removeContentPack("characters:shared-first");
expect(await active.match(objectPath)).toBeDefined();
expect(contentPackIsUsable("characters:shared-second")).toBe(true);
await removeContentPack("characters:shared-second");
expect(active.responses.size).toBe(0);
});
it("rejects a valid gzip payload that expands to the wrong original model", async () => {
const original = new TextEncoder().encode("expected model");
const compressed = gzipSync("different model");
const base = contentManifest("bad-compressed", "model.glb", original);
const manifest = { ...base, files: { "/assets/characters/test.glb": { ...base.files["/assets/characters/test.glb"],
download: { url: "/content/objects/model.glb.gz", encoding: "gzip", sha256: sha256(compressed), bytes: compressed.length },
} } };
const storage = new MemoryCacheStorage();
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
vi.stubGlobal("caches", storage as unknown as CacheStorage);
vi.stubGlobal("fetch", vi.fn(async (url: RequestInfo | URL) => String(url).endsWith("manifest.json") ? Response.json(manifest) : new Response(compressed)));
await checkForContentUpdates();
await expect(installContentPack("characters:test")).rejects.toThrow(/expanded content failed verification/i);
expect(storage.stores.get(BROWSER_CONTENT_CACHE_NAME)?.responses.size).toBe(0);
expect(contentPackIsUsable("characters:test")).toBe(false);
});
it("uses source-served dungeons in development and the content gate in shipped builds", () => {
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
vi.stubEnv("DEV", true);
+104 -61
View File
@@ -32,6 +32,8 @@ export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1";
export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js";
const BROWSER_CONTENT_SHA256_HEADER = "x-healer-man-content-sha256";
const BROWSER_CONTENT_BYTES_HEADER = "x-healer-man-content-bytes";
const BROWSER_CONTENT_OBJECT_HEADER = "x-healer-man-content-object";
const BROWSER_CONTENT_ENCODING_HEADER = "x-healer-man-content-encoding";
export interface InstalledContentFile {
readonly packId: string;
@@ -178,6 +180,23 @@ function browserCacheRequest(logicalPath: string): Request {
return new Request(new URL(logicalPath, `${appOrigin}/`).toString(), { method: "GET" });
}
export function browserContentObjectPath(entry: { readonly sha256: string; readonly extension?: string; readonly url?: string }): string {
const extension = entry.extension ?? extensionForEntry(entry as ContentManifestFile);
return `/content/cache-objects/${entry.sha256}${extension}`;
}
function verifiedCachedResponse(response: Response | undefined, entry: { readonly sha256: string; readonly bytes: number }): boolean {
return response?.headers.get(BROWSER_CONTENT_SHA256_HEADER) === entry.sha256
&& response.headers.get(BROWSER_CONTENT_BYTES_HEADER) === String(entry.bytes);
}
async function cachedLogicalObject(cache: Cache, logicalPath: string): Promise<Response | undefined> {
const alias = await cache.match(browserCacheRequest(logicalPath));
const objectPath = alias?.headers.get(BROWSER_CONTENT_OBJECT_HEADER);
if (!objectPath) return alias;
return objectPath.startsWith("/content/cache-objects/") ? cache.match(browserCacheRequest(objectPath)) : undefined;
}
function browserCacheStorage(): CacheStorage | null {
return typeof caches === "undefined" ? null : caches;
}
@@ -209,9 +228,8 @@ async function validateInstalledContentStorage(): Promise<void> {
const active = storage ? await storage.open(BROWSER_CONTENT_CACHE_NAME) : null;
if (active) {
for (const [logicalPath, entry] of Object.entries(installedState.files)) {
const cached = await active.match(browserCacheRequest(logicalPath));
if (cached?.headers.get(BROWSER_CONTENT_SHA256_HEADER) === entry.sha256
&& cached.headers.get(BROWSER_CONTENT_BYTES_HEADER) === String(entry.bytes)) {
const cached = await cachedLogicalObject(active, logicalPath);
if (verifiedCachedResponse(cached, entry)) {
validFiles[logicalPath] = entry;
}
}
@@ -246,7 +264,8 @@ async function registerBrowserContentWorker(): Promise<void> {
scope: "/",
updateViaCache: "none",
});
await navigator.serviceWorker.ready;
const registration = await navigator.serviceWorker.ready;
registration.active?.postMessage({ type: "refresh-content-bootstrap" });
} catch (error) {
console.warn("Browser content cache worker could not be registered.", error);
}
@@ -305,9 +324,9 @@ export function resolveContentUrl(logicalUrl: string): string {
}
export function contentPackIsBundled(packId: string): boolean {
if (!Capacitor.isNativePlatform()) return STATIC_BROWSER_BUNDLED_PACKS.has(packId);
return STATIC_ANDROID_BUNDLED_PACKS.has(packId)
|| Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId));
const defaults = Capacitor.isNativePlatform() ? STATIC_ANDROID_BUNDLED_PACKS : STATIC_BROWSER_BUNDLED_PACKS;
return defaults.has(packId)
|| Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId && pack.bundledOnAndroid));
}
export function contentPackIsInstalled(packId: string): boolean {
@@ -323,6 +342,8 @@ function matchingReceiptFiles(
if (Object.keys(receipt.files).length !== expectedEntries.length) return false;
return expectedEntries.every(([logicalPath, expected]) => {
const recorded = receipt.files[logicalPath];
if (recorded?.sha256 === expected.sha256 && recorded.bytes === expected.bytes
&& snapshot.bundledManifest?.files[logicalPath]?.sha256 === expected.sha256) return true;
const installed = installedState.files[logicalPath];
return recorded?.sha256 === expected.sha256
&& recorded.bytes === expected.bytes
@@ -394,7 +415,7 @@ export async function checkForContentUpdates(): Promise<ContentManifest | null>
async function loadBundledManifest(): Promise<void> {
if (!Capacitor.isNativePlatform() && import.meta.env.VITE_CONTENT_MODE !== "downloadable") return;
try {
const response = await fetch("/content/bootstrap-manifest.json", { cache: "force-cache" });
const response = await fetch("/content/bootstrap-manifest.json", { cache: Capacitor.isNativePlatform() ? "force-cache" : "no-cache" });
if (!response.ok) return;
publish({ bundledManifest: parseContentManifest(await response.json()) });
} catch {
@@ -431,46 +452,63 @@ async function installBrowserContentPack(
): Promise<void> {
const storage = browserCacheStorage();
if (!storage) throw new Error("Offline content storage is unavailable in this browser.");
const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0);
const active = await storage.open(BROWSER_CONTENT_CACHE_NAME);
const objects = new Map<string, { logicalPath: string; entry: ContentManifestFile; cached?: Response }>();
for (const [logicalPath, entry] of changes) {
const objectPath = browserContentObjectPath(entry);
if (objects.has(objectPath)) continue;
let cached = await active.match(browserCacheRequest(objectPath));
if (!verifiedCachedResponse(cached, entry)) cached = await cachedLogicalObject(active, logicalPath);
objects.set(objectPath, { logicalPath, entry, cached: verifiedCachedResponse(cached, entry) ? cached : undefined });
}
const totalBytes = [...objects.values()].reduce((sum, object) => sum + (object.cached ? 0 : object.entry.download?.bytes ?? object.entry.bytes), 0);
const stagingName = `${BROWSER_CONTENT_CACHE_NAME}-staging-${encodeURIComponent(packId)}`;
await storage.delete(stagingName);
const staging = await storage.open(stagingName);
publish({ installingPackId: packId, downloadedBytes: 0, totalBytes, error: "" });
let completedBytes = 0;
try {
for (const [logicalPath, entry] of changes) {
const response = await fetch(absoluteContentUrl(entry.url), { cache: "no-store" });
if (!response.ok) throw new Error(`Content request failed (${response.status}): ${logicalPath}`);
const bytes = await response.arrayBuffer();
if (bytes.byteLength !== entry.bytes) {
throw new Error(`Downloaded content has the wrong size: ${logicalPath}`);
for (const [objectPath, { logicalPath, entry, cached }] of objects) {
if (cached) {
await staging.put(browserCacheRequest(objectPath), cached);
continue;
}
if (await sha256Bytes(bytes) !== entry.sha256) {
throw new Error(`Downloaded content failed verification: ${logicalPath}`);
const download = entry.download ?? entry;
const response = await fetch(absoluteContentUrl(download.url), { cache: "no-store" });
if (!response.ok) throw new Error(`Content request failed (${response.status}): ${logicalPath}`);
const stored = await response.arrayBuffer();
if (stored.byteLength !== download.bytes) throw new Error(`Downloaded content has the wrong size: ${logicalPath}`);
if (await sha256Bytes(stored) !== download.sha256) throw new Error(`Downloaded content failed verification: ${logicalPath}`);
if (entry.download) {
const expanded = await new Response(new Blob([stored]).stream().pipeThrough(new DecompressionStream("gzip"))).arrayBuffer();
if (expanded.byteLength !== entry.bytes || await sha256Bytes(expanded) !== entry.sha256) {
throw new Error(`Expanded content failed verification: ${logicalPath}`);
}
}
const headers = new Headers();
const contentType = response.headers.get("content-type");
if (contentType) headers.set("content-type", contentType);
if (!entry.download && response.headers.get("content-type")) headers.set("content-type", response.headers.get("content-type")!);
headers.set(BROWSER_CONTENT_SHA256_HEADER, entry.sha256);
headers.set(BROWSER_CONTENT_BYTES_HEADER, String(entry.bytes));
await staging.put(browserCacheRequest(logicalPath), new Response(bytes, {
headers,
status: 200,
}));
completedBytes += entry.bytes;
if (entry.download) headers.set(BROWSER_CONTENT_ENCODING_HEADER, "gzip");
await staging.put(browserCacheRequest(objectPath), new Response(stored, { headers, status: 200 }));
completedBytes += download.bytes;
publish({ downloadedBytes: completedBytes });
}
const active = await storage.open(BROWSER_CONTENT_CACHE_NAME);
for (const [logicalPath] of changes) {
const cached = await staging.match(browserCacheRequest(logicalPath));
if (!cached) throw new Error(`Verified content was not staged: ${logicalPath}`);
await active.put(browserCacheRequest(logicalPath), cached);
// Commit every verified object before switching any logical URL to the new version.
for (const objectPath of objects.keys()) {
const cached = await staging.match(browserCacheRequest(objectPath));
if (!cached) throw new Error(`Verified content was not staged: ${objectPath}`);
await active.put(browserCacheRequest(objectPath), cached);
}
const pendingFiles = { ...installedState.files };
for (const [logicalPath, entry] of changes) {
await active.put(browserCacheRequest(logicalPath), new Response(null, { headers: {
[BROWSER_CONTENT_OBJECT_HEADER]: browserContentObjectPath(entry),
[BROWSER_CONTENT_SHA256_HEADER]: entry.sha256,
[BROWSER_CONTENT_BYTES_HEADER]: String(entry.bytes),
} }));
}
const previousFiles = installedState.files;
const pendingFiles = { ...previousFiles };
const latestPaths = new Set(filesForContentPack(manifest, packId).map(([logicalPath]) => logicalPath));
for (const [logicalPath, installed] of Object.entries(pendingFiles)) {
if (installed.packId !== packId || latestPaths.has(logicalPath)) continue;
@@ -479,31 +517,21 @@ async function installBrowserContentPack(
validatedInstalledFiles.delete(logicalPath);
}
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
pendingFiles[logicalPath] = {
packId,
sha256: entry.sha256,
bytes: entry.bytes,
extension: extensionForEntry(entry),
fileUri: absoluteContentUrl(logicalPath),
};
if (snapshot.bundledManifest?.files[logicalPath]?.sha256 === entry.sha256) continue;
pendingFiles[logicalPath] = { packId, sha256: entry.sha256, bytes: entry.bytes,
extension: extensionForEntry(entry), fileUri: absoluteContentUrl(logicalPath) };
validatedInstalledFiles.add(logicalPath);
}
installedState = {
version: 2,
files: pendingFiles,
packs: {
...installedState.packs,
[packId]: packReceiptForManifest(manifest, packId),
},
};
installedState = { version: 2, files: pendingFiles, packs: {
...installedState.packs, [packId]: packReceiptForManifest(manifest, packId),
} };
persistInstalledState();
publish({
installingPackId: null,
downloadedBytes: totalBytes,
totalBytes,
error: "",
installedRevision: snapshot.installedRevision + 1,
});
const retained = new Set(Object.values(pendingFiles).map(browserContentObjectPath));
for (const previous of Object.values(previousFiles)) {
const objectPath = browserContentObjectPath(previous);
if (!retained.has(objectPath)) await active.delete(browserCacheRequest(objectPath));
}
publish({ installingPackId: null, downloadedBytes: totalBytes, totalBytes, error: "", installedRevision: snapshot.installedRevision + 1 });
} catch (error) {
publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." });
throw error;
@@ -535,11 +563,17 @@ export async function installContentPack(packId: string): Promise<void> {
await installBrowserContentPack(packId, manifest, changes);
return;
}
const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0);
const existingObjects = new Set(Object.entries(installedState.files)
.filter(([logicalPath]) => validatedInstalledFiles.has(logicalPath))
.map(([, entry]) => browserContentObjectPath(entry)));
const transferObjects = new Map(changes.map(([, entry]) => [browserContentObjectPath(entry), entry]));
const missingObjects = [...transferObjects].filter(([key]) => !existingObjects.has(key));
const totalBytes = missingObjects.reduce((total, [, entry]) => total + (entry.download?.bytes ?? entry.bytes), 0);
const installedBytes = missingObjects.reduce((total, [, entry]) => total + entry.bytes, 0);
const storage = await NativeContentStorage.getFreeBytes();
const safetyReserve = 128 * 1024 * 1024;
if (storage.freeBytes < totalBytes + safetyReserve) {
const error = `Not enough free storage. ${Math.ceil((totalBytes + safetyReserve - storage.freeBytes) / 1024 / 1024)} MB more is required.`;
if (storage.freeBytes < installedBytes + safetyReserve) {
const error = `Not enough free storage. ${Math.ceil((installedBytes + safetyReserve - storage.freeBytes) / 1024 / 1024)} MB more is required.`;
publish({ error });
throw new Error(error);
}
@@ -554,11 +588,12 @@ export async function installContentPack(packId: string): Promise<void> {
for (const [logicalPath, entry] of changes) {
const extension = extensionForEntry(entry);
const result = await NativeContentStorage.download({
url: absoluteContentUrl(entry.url),
url: absoluteContentUrl(entry.download?.url ?? entry.url),
logicalPath,
sha256: entry.sha256,
extension,
bytes: entry.bytes,
...(entry.download ? { encoding: entry.download.encoding, downloadSha256: entry.download.sha256, downloadBytes: entry.download.bytes } : {}),
});
pendingFiles[logicalPath] = {
packId,
@@ -567,7 +602,9 @@ export async function installContentPack(packId: string): Promise<void> {
extension,
fileUri: result.fileUri,
};
completedBytes += entry.bytes;
const objectKey = browserContentObjectPath(entry);
if (!existingObjects.has(objectKey)) completedBytes += entry.download?.bytes ?? entry.bytes;
existingObjects.add(objectKey);
publish({ downloadedBytes: completedBytes });
}
@@ -580,6 +617,7 @@ export async function installContentPack(packId: string): Promise<void> {
}
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
const installed = pendingFiles[logicalPath];
if (!installed && snapshot.bundledManifest?.files[logicalPath]?.sha256 === entry.sha256) continue;
if (!installed) throw new Error(`Verified content is missing after installation: ${logicalPath}`);
pendingFiles[logicalPath] = {
...installed,
@@ -634,6 +672,11 @@ export async function removeContentPack(packId: string): Promise<void> {
await active.delete(browserCacheRequest(logicalPath));
delete remainingFiles[logicalPath];
}
const retainedObjects = new Set(Object.values(remainingFiles).map(browserContentObjectPath));
for (const [, entry] of targetFiles) {
const objectPath = browserContentObjectPath(entry);
if (!retainedObjects.has(objectPath)) await active.delete(browserCacheRequest(objectPath));
}
}
targetFiles.forEach(([logicalPath]) => validatedInstalledFiles.delete(logicalPath));
const packs = { ...installedState.packs };
@@ -130,6 +130,25 @@ afterEach(() => {
});
describe("persisted content readiness", () => {
it.each([false, true])("honors explicit bundle flags without installing shared Manastorm maps (native=%s)", async (native) => {
const { manager } = await loadOfflineManager({ version: 2, files: {}, packs: {} }, []);
vi.stubEnv("VITE_CONTENT_MODE", "downloadable");
const { Capacitor } = await import("@capacitor/core");
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(native);
vi.stubGlobal("fetch", vi.fn(async () => Response.json({
schemaVersion: 1, version: "bundled", createdAt: "2026-09-10", minimumAppVersion: "0.1.0",
packs: [
{ id: "dungeon:test", label: "Test", bytes: 0, fileCount: 0, bundledOnAndroid: true, dependencies: ["manastorm:test"] },
{ id: "manastorm:test", label: "Map", bytes: 11, fileCount: 1, bundledOnAndroid: false, dependencies: [] },
],
files: { [ENVIRONMENT_PATH]: { packId: "manastorm:test", url: "/content/bundled/model.glb", sha256: ENVIRONMENT_HASH, bytes: 11 } },
})));
await manager.initializeContentSystem();
expect(manager.dungeonContentIsUsable("test")).toBe(true);
expect(manager.contentPackIsBundled("manastorm:test")).toBe(false);
expect(manager.contentPackIsInstalled("manastorm:test")).toBe(false);
});
it("restores a complete dependency-only dungeon closure offline after verifying storage", async () => {
const { manager } = await loadOfflineManager(installedState(), [ENVIRONMENT_PATH, CREATURE_PATH]);
+9
View File
@@ -31,6 +31,15 @@ describe("content manifest", () => {
expect(appVersionMeetsMinimum("1.1.9", "1.2.0")).toBe(false);
});
it("rejects unsupported or unverified compressed representations", () => {
const withDownload = (download: unknown) => ({ ...manifest, files: { "/assets/a.glb": { ...manifest.files["/assets/a.glb"], download } } });
const download = { url: "/content/objects/a.glb.gz", encoding: "gzip", sha256: "c".repeat(64), bytes: 10 };
expect(() => parseContentManifest(withDownload(download))).not.toThrow();
expect(() => parseContentManifest(withDownload({ ...download, encoding: "unknown" }))).toThrow(/compressed file/i);
expect(() => parseContentManifest(withDownload({ ...download, sha256: "invalid" }))).toThrow(/compressed file/i);
expect(() => parseContentManifest(withDownload({ ...download, bytes: -1 }))).toThrow(/compressed file/i);
});
it("downloads only files whose verified hash changed", () => {
expect(filesForContentPack(manifest, "dungeon:test")).toHaveLength(2);
expect(changedFilesForPack(manifest, "dungeon:test", {
+15
View File
@@ -5,12 +5,19 @@ export interface ContentManifestFile {
readonly url: string;
readonly sha256: string;
readonly bytes: number;
readonly download?: {
readonly url: string;
readonly encoding: "gzip";
readonly sha256: string;
readonly bytes: number;
};
}
export interface ContentManifestPack {
readonly id: string;
readonly label: string;
readonly bytes: number;
readonly downloadBytes?: number;
readonly fileCount: number;
readonly bundledOnAndroid: boolean;
readonly dependencies: readonly string[];
@@ -63,6 +70,14 @@ export function parseContentManifest(value: unknown): ContentManifest {
|| entry.bytes < 0) {
throw new Error(`The content catalog contains a malformed file: ${logicalPath}`);
}
if (entry.download !== undefined && (!isRecord(entry.download)
|| entry.download.encoding !== "gzip"
|| typeof entry.download.url !== "string"
|| !/^[a-f0-9]{64}$/.test(String(entry.download.sha256))
|| !Number.isSafeInteger(entry.download.bytes)
|| Number(entry.download.bytes) < 0)) {
throw new Error(`The content catalog contains a malformed compressed file: ${logicalPath}`);
}
return [logicalPath, entry as unknown as ContentManifestFile];
}));
return { ...value, packs, files } as unknown as ContentManifest;
+3
View File
@@ -18,6 +18,9 @@ interface DownloadOptions {
readonly sha256: string;
readonly extension: string;
readonly bytes: number;
readonly encoding?: "gzip";
readonly downloadSha256?: string;
readonly downloadBytes?: number;
}
interface ObjectOptions {
+4 -1
View File
@@ -948,9 +948,12 @@ export function abilityAtLevel(
learnedTalentRank?: number,
): AbilityDefinition {
const rank = abilityRankForLevel(ability, playerLevel, learnedTalentRank);
if (!rank) return ability;
const effects = rank?.effects ?? ability.effects;
const target = effects.length > 0 && effects.every((effect) => effect.kind === "dispel" && effect.relationship === "target" && effect.mode === "cleanse") ? "any" : ability.target;
if (!rank) return target === ability.target ? ability : { ...ability, target };
return {
...ability,
target,
dbcSpellId: rank.spellId,
description: rank.description || ability.description,
castMode: rank.castMode,
+1 -1
View File
@@ -59,7 +59,7 @@ export function targetingRulesForAbility(ability: AbilityDefinition): AbilityTar
attackArcRadians: shape === "cone" ? Math.PI / 2 : Math.PI,
...(ability.radius ? { radius: ability.radius } : {}),
...(shape === "chain" ? { maximumTargets: 3, jumpRange: 12 } : {}),
...(shape === "ground" ? { groundRange: ability.range.max, radius: ability.radius ?? 8 } : {}),
...(shape === "ground" ? { groundRange: ability.range.max > 0 ? ability.range.max : name.endsWith(" trap") || name === "consecration" ? 0 : 30, radius: ability.radius ?? 8 } : {}),
});
}
+31 -10
View File
@@ -1,19 +1,40 @@
import { BoxGeometry, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from "three";
import { describe, expect, it } from "vitest";
import { arenaCameraPose } from "./arenaCamera";
import { arenaCameraClearDistance, arenaCameraPose, arenaSpawnYaw, ARENA_CAMERA_DISTANCE } from "./arenaCamera";
import { ARENA_STAGES } from "./arenaStages";
describe("arena camera placement", () => {
it("uses a finite overview anchor aimed at the playable center of every arena", () => {
describe("arena player camera", () => {
it("starts behind each character at the playable floor on all eight maps", () => {
for (const stage of ARENA_STAGES) {
for (const team of [0, 1] as const) {
const { position, target } = arenaCameraPose(stage, team);
expect(position).toEqual(stage.spectatorPositions[team]);
expect(position[0], `${stage.id} team ${team} x`).toBeGreaterThan(stage.bounds.min[0]);
expect(position[0], `${stage.id} team ${team} x`).toBeLessThan(stage.bounds.max[0]);
expect(position[2], `${stage.id} team ${team} z`).toBeGreaterThan(stage.bounds.min[2]);
expect(position[2], `${stage.id} team ${team} z`).toBeLessThan(stage.bounds.max[2]);
expect(target.every(Number.isFinite)).toBe(true);
const foot = stage.teamSpawns[team];
const { position, target } = arenaCameraPose(foot, arenaSpawnYaw(stage, team));
expect(target[0]).toBe(foot[0]);
expect(target[2]).toBe(foot[2]);
expect(target[1] - foot[1]).toBeCloseTo(1.35);
expect(new Vector3(...position).distanceTo(new Vector3(...target))).toBeCloseTo(ARENA_CAMERA_DISTANCE);
expect(position[1] - foot[1]).toBeLessThan(4);
}
}
});
it("follows player translation and responds to orbit input", () => {
const first = arenaCameraPose([1, -22, 3], 0);
const moved = arenaCameraPose([11, -20, 5], 0);
expect(moved.position.map((value, axis) => value - first.position[axis])).toEqual([10, 2, 2]);
expect(arenaCameraPose([1, -22, 3], Math.PI / 2).position).not.toEqual(first.position);
expect(arenaCameraPose([0, 0, 0], NaN, Infinity).position.every(Number.isFinite)).toBe(true);
});
it("pulls in before a wall and restores full distance in open space", () => {
const wall = new Mesh(new BoxGeometry(8, 8, 0.2), new MeshBasicMaterial());
wall.position.set(0, 1.5, -2);
wall.updateMatrixWorld(true);
const target = new Vector3(0, 1.5, 0);
const ray = new Raycaster();
expect(arenaCameraClearDistance(target, new Vector3(0, 1.5, -6), wall, ray)).toBeCloseTo(1.7);
expect(arenaCameraClearDistance(target, new Vector3(6, 1.5, 0), wall, ray)).toBe(6);
wall.geometry.dispose();
wall.material.dispose();
});
});
+47 -7
View File
@@ -1,16 +1,56 @@
import type { ArenaStageManifest } from "./arenaTypes";
import { Raycaster, Vector3, type Object3D } from "three";
import { clampCameraPitch, DEFAULT_CAMERA_PITCH } from "./inputMath";
import type { ArenaStageManifest, Vec3Tuple } from "./arenaTypes";
export const ARENA_CAMERA_DISTANCE = 5.8;
export const ARENA_CAMERA_FOCUS_HEIGHT = 1.35;
export interface ArenaCameraPose {
readonly position: [number, number, number];
readonly target: [number, number, number];
}
export function arenaCameraPose(stage: ArenaStageManifest, team: 0 | 1): ArenaCameraPose {
const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2;
const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2;
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1]));
export function arenaSpawnYaw(stage: ArenaStageManifest, team: 0 | 1): number {
const from = stage.teamSpawns[team];
const to = stage.preparationGates[team];
return Math.atan2(to[0] - from[0], to[2] - from[2]);
}
/** Follow the controlled character at their elevation, including underground arenas. */
export function arenaCameraPose(
footPosition: Vec3Tuple,
yaw: number,
pitch = DEFAULT_CAMERA_PITCH,
distance = ARENA_CAMERA_DISTANCE,
): ArenaCameraPose {
const safeYaw = Number.isFinite(yaw) ? yaw : 0;
const safePitch = clampCameraPitch(pitch);
const radius = Number.isFinite(distance) ? Math.max(0.25, Math.min(12, distance)) : ARENA_CAMERA_DISTANCE;
const target: [number, number, number] = [footPosition[0], footPosition[1] + ARENA_CAMERA_FOCUS_HEIGHT, footPosition[2]];
return {
position: [...stage.spectatorPositions[team]],
target: [centerX, floorY + 1.2, centerZ],
target,
position: [
target[0] - Math.sin(safeYaw) * Math.cos(safePitch) * radius,
target[1] + Math.sin(safePitch) * radius,
target[2] - Math.cos(safeYaw) * Math.cos(safePitch) * radius,
],
};
}
/** Probe both directions so one-sided imported walls also obstruct the camera. */
export function arenaCameraClearDistance(target: Vector3, desired: Vector3, environment: Object3D, ray: Raycaster): number {
const length = target.distanceTo(desired);
if (length < 0.001) return 0;
const direction = desired.clone().sub(target).normalize();
let distance = length;
for (const offset of [[0, 0, 0], [0.18, 0, 0], [-0.18, 0, 0], [0, 0.18, 0], [0, -0.18, 0]]) {
const origin = target.clone().add(new Vector3(...offset));
ray.near = 0.03;
ray.far = length;
ray.set(origin, direction);
for (const hit of ray.intersectObject(environment, true)) distance = Math.min(distance, hit.distance - 0.2);
ray.set(origin.addScaledVector(direction, length), direction.clone().negate());
for (const hit of ray.intersectObject(environment, true)) distance = Math.min(distance, length - hit.distance - 0.2);
}
return Math.max(0.15, distance);
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import type { CharacterProfile } from "../app/types";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { createArenaCharacterKit } from "./arenaCharacter";
import { controlledArenaActor } from "./arenaPlayer";
import type { ArenaAuthoritativeSnapshot } from "./arenaTypes";
describe("arena character selection and saved actions", () => {
it("keeps selected character bindings and enough mana to use learned spells", async () => {
await preloadAbilityCatalogsForClasses(["priest"]);
const character = { id: "mine", name: "My Priest", classId: "priest", raceId: "human", level: 80,
equipment: {}, inventory: [], talentRanks: {}, actionBindings: { "primary:face-left": "priest-flash-heal", "primary:r1": null } } as unknown as CharacterProfile;
const kit = createArenaCharacterKit(character);
expect(kit.actionBindings["primary:face-left"]).toBe("priest-flash-heal");
expect(kit.actionBindings["primary:r1"]).toBeNull();
expect(kit.resource.maximum).toBeGreaterThan(kit.abilities["priest-flash-heal"].cost.amount);
expect(kit.abilities.heal).toBeUndefined();
expect(kit.stats?.health).toBeGreaterThan(0);
});
it("uses server control ownership instead of selecting an arbitrary friendly bot", () => {
const actors = [{ id: "bot", team: 0, isBot: true }, { id: "mine", team: 1, isBot: false, characterId: "saved" }];
const snapshot = { mode: "ranked-pvp", controlledActorId: "mine", actors } as unknown as ArenaAuthoritativeSnapshot;
expect(controlledArenaActor(snapshot, { id: "saved" })?.id).toBe("mine");
expect(controlledArenaActor({ ...snapshot, controlledActorId: undefined }, { id: "saved" })?.id).toBe("mine");
expect(controlledArenaActor({ ...snapshot, controlledActorId: undefined }, { id: "missing" })).toBeNull();
});
});
+42
View File
@@ -0,0 +1,42 @@
import type { CharacterProfile } from "../app/types";
import {
abilitiesForCharacter, abilityAtLevel, defaultActionBarForCharacter, resourceProfileForClass,
type AbilityDefinition, type ClassResourceProfile,
} from "./abilityCatalog";
import { normalizeActionBindings } from "./actionBindings";
import type { ArenaCharacterKit } from "./arenaTypes";
import { deriveCharacterStats } from "./wotlkStats";
import { activeEquipmentStatsForClass, PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
export function arenaCharacterAbilities(character: CharacterProfile, level = character.level): readonly AbilityDefinition[] {
return abilitiesForCharacter(character.classId, character.secondaryClassId)
.filter((ability) => !ability.passive)
.map((ability) => abilityAtLevel(ability, level, ability.talentEntryId === undefined
? undefined : character.talentRanks[`coa-entry-${ability.talentEntryId}`]));
}
export function arenaAbilityIsLearned(ability: AbilityDefinition, character: CharacterProfile, level = character.level): boolean {
return ability.unlockLevel <= level && (ability.source !== "coa-tree" || ability.talentEntryId === undefined
|| (character.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0);
}
export function createArenaCharacterKit(character: CharacterProfile, level = character.level): ArenaCharacterKit {
const stats = deriveCharacterStats(character.classId, level, activeEquipmentStatsForClass(character.equipment, character.inventory, PLAYER_EQUIPMENT_OWNER_ID, character.classId), character.raceId, {}, character.secondaryClassId);
const profile = resourceProfileForClass(character.classId);
const resource = profile.type === "mana" ? { ...profile, maximum: stats.mana, initial: stats.mana, regenerationPerSecond: stats.mana * 0.004 } : profile;
const abilities = arenaCharacterAbilities(character, level).filter((ability) => arenaAbilityIsLearned(ability, character, level));
const all = abilitiesForCharacter(character.classId, character.secondaryClassId);
const actionBindings = normalizeActionBindings(character.actionBindings, [
...defaultActionBarForCharacter(character.classId, character.secondaryClassId, level),
...all.filter((ability) => ability.source === "coa-tree" && !ability.passive).map((ability) => ability.id),
]);
return {
seed: { ...character, level, settings: { ...character.settings, autoTargetNearest: false } },
classId: character.classId,
level,
resource: resource as ClassResourceProfile,
stats: { health: stats.health, spellPower: stats.spellPower },
abilities: Object.fromEntries(abilities.map((ability) => [ability.id, ability])),
actionBindings,
};
}
+35
View File
@@ -0,0 +1,35 @@
import type { CharacterProfile } from "../app/types";
import type { ArenaActorSnapshot, ArenaAuthoritativeSnapshot } from "./arenaTypes";
export function controlledArenaActor(snapshot: ArenaAuthoritativeSnapshot | null, character: Pick<CharacterProfile, "id"> | null): ArenaActorSnapshot | null {
if (!snapshot) return null;
return snapshot.actors.find((actor) => actor.id === snapshot.controlledActorId)
?? snapshot.actors.find((actor) => character && actor.characterId === character.id && !actor.isBot)
?? (snapshot.mode === "practice" ? snapshot.actors.find((actor) => actor.id === "practice-player") : null)
?? null;
}
export function arenaTargetActor(snapshot: ArenaAuthoritativeSnapshot | null, id: string | null): ArenaActorSnapshot | null {
const player = snapshot?.actors.find((actor) => actor.id === id);
if (player) return player;
for (const owner of snapshot?.actors ?? []) {
const summon = owner.combat?.summons.find((entry) => entry.id === id);
if (summon) return { ...owner, id: summon.id, name: summon.name, position: summon.position,
health: summon.health, maxHealth: summon.maxHealth, alive: summon.health > 0 };
}
return null;
}
export function arenaIntentFeedback(reason?: string): string {
return ({
"preparation": "Wait for the gates to open.", "invalid-target": "Select a living target.",
"out-of-range": "Target is out of range.", "line-of-sight": "Target is out of sight.",
"resource": "Not enough resource.", "cooldown": "Ability is not ready yet.",
"already-casting": "Already casting.", "moving": "Stop moving to cast.",
"controlled": "You cannot cast while controlled or silenced.", "not-learned": "That ability is not learned by this character.",
"not-facing": "Face your target.", "must-be-behind": "Move behind your target.",
"invalid-destination": "Choose a reachable location in range.", "insufficient-resource": "Not enough resource.",
"school-locked": "That spell school is locked.", "player-controlled": "You cannot act while controlled.",
"actor-unavailable": "You cannot act right now.",
} as Record<string, string>)[reason ?? ""] ?? "Unable to use that ability.";
}
+2
View File
@@ -1,6 +1,7 @@
import type { CharacterProfile } from "../app/types";
import { ArenaSimulation } from "../../server/arena-simulation.mjs";
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaResult, ArenaRole, ArenaStageManifest } from "./arenaTypes";
import { createArenaCharacterKit } from "./arenaCharacter";
interface PracticeOptions {
character: CharacterProfile;
@@ -33,6 +34,7 @@ export function createArenaPractice(options: PracticeOptions) {
role,
isBot: index !== 0,
rating: options.difficulty,
characterKit: index === 0 ? createArenaCharacterKit(options.character) : undefined,
})),
...teamRoles(teamSize).map((role, index) => ({
actorId: `practice-enemy-${index}`,
+26 -11
View File
@@ -13,9 +13,11 @@ import type { CharacterProfile, PlayerSession } from "../app/types";
import { useShellStore } from "../app/shellStore";
import { arenaAiProfile } from "./arenaRules";
import { ARENA_STAGES } from "./arenaStages";
import { createArenaPractice } from "./arenaPractice";
import type { createArenaPractice } from "./arenaPractice";
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaLoadout, ArenaQueueState, ArenaResult } from "./arenaTypes";
import { romGearProfile, specializationById, specializationsForClass } from "./specializationCatalog";
import { arenaIntentFeedback } from "./arenaPlayer";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
type ArenaModeSelection = "offline-practice" | "rated-ai" | "ranked-pvp";
@@ -74,6 +76,7 @@ function handleSocketMessage(raw: unknown) {
}
if (message.type === "result" && message.result) useArenaStore.setState({ result: message.result, loading: false });
if (message.type === "queue-cancelled") useArenaStore.setState({ queue: { status: "queued" }, error: message.message ?? "Ready check cancelled." });
if (message.type === "intent-rejected") useArenaStore.setState({ error: message.message || arenaIntentFeedback((raw as { reason?: string }).reason) });
}
async function connectArenaSocket(session: PlayerSession) {
@@ -180,15 +183,23 @@ export const useArenaStore = create<ArenaStoreState>((set, get) => ({
return false;
}
if (state.mode === "offline-practice") {
const practice = createArenaPractice({
character, role: fallback.role as "healer" | "damage", bracket: state.bracket,
ruleset: state.ruleset, stage, difficulty: state.botDifficulty,
onSnapshot: (snapshot) => set({ snapshot }),
onResult: (result) => set({ result }),
});
set({ practice, error: "", result: null, queue: { status: "matched", matchId: practice ? `practice-${Date.now()}` : undefined } });
useShellStore.setState({ phase: "arena-game" });
return true;
set({ loading: true, error: "" });
try {
await preloadAbilityCatalogsForClasses([character.classId, ...(character.secondaryClassId ? [character.secondaryClassId] : [])]);
const { createArenaPractice } = await import("./arenaPractice");
const practice = createArenaPractice({
character, role: fallback.role as "healer" | "damage", bracket: state.bracket,
ruleset: state.ruleset, stage, difficulty: state.botDifficulty,
onSnapshot: (snapshot) => set({ snapshot }),
onResult: (result) => set({ result }),
});
set({ practice, loading: false, error: "", result: null, queue: { status: "matched", matchId: `practice-${Date.now()}` } });
useShellStore.setState({ phase: "arena-game" });
return true;
} catch (error) {
set({ loading: false, error: messageFor(error) });
return false;
}
}
if (!session?.accessToken) {
set({ error: "Rated arenas require an online account. Offline Practice remains available." });
@@ -235,7 +246,11 @@ export const useArenaStore = create<ArenaStoreState>((set, get) => ({
const sequence = get().sequence + 1;
const message = { ...intent, sequence } as ArenaCombatIntent;
set({ sequence });
get().practice?.intent(message);
if (intent.type === "target") set((state) => ({ snapshot: state.snapshot ? { ...state.snapshot,
actors: state.snapshot.actors.map((actor) => actor.id === state.snapshot!.controlledActorId
? { ...actor, targetActorId: intent.targetActorId || null } : actor) } : null }));
const response = get().practice?.intent(message);
if (intent.type === "ability") set({ error: response && !response.accepted ? response.message ?? arenaIntentFeedback(response.reason) : "" });
const socket = get().socket;
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
},
+46 -2
View File
@@ -1,4 +1,15 @@
import type { ArenaBracket, ArenaMode, ArenaRuleset } from "./arenaRules";
import type { AbilityDefinition, ClassResourceProfile } from "./abilityCatalog";
export interface ArenaCharacterKit {
readonly seed?: import("./combatStore").CharacterCombatSeed;
readonly stats?: { readonly health: number; readonly spellPower: number };
classId: string;
level: number;
resource: ClassResourceProfile;
abilities: Readonly<Record<string, AbilityDefinition>>;
actionBindings: Readonly<Record<string, string | null>>;
}
export type Vec3Tuple = readonly [number, number, number];
export type ArenaRole = "healer" | "damage";
@@ -95,7 +106,10 @@ export interface ArenaQueueState {
export interface ArenaCombatIntent {
sequence: number;
type: "movement" | "facing" | "target" | "ability";
type: "movement" | "facing" | "target" | "ability" | "command" | "jump" | "bindings";
command?: "attack" | "defend" | "stop" | "recall" | "cancel-cast";
actionBindings?: Readonly<Record<string, string | null>>;
groundPosition?: Vec3Tuple;
movement?: { x: number; z: number };
facing?: number;
targetActorId?: string;
@@ -104,7 +118,35 @@ export interface ArenaCombatIntent {
}
export interface ArenaActorSnapshot {
avatar?: import("../avatar/CharacterModel").AvatarIdentity;
visualEquipment?: readonly import("./lootTypes").InventoryItem[];
combat?: {
resourcePools: import("./combatStore").CombatState["resourcePools"];
maxResourcePools: import("./combatStore").CombatState["maxResourcePools"];
runes: import("./combatStore").CombatState["deathKnightRunes"];
comboPoints: number;
comboTargetId: string | null;
shield: number;
activeCast: import("./combatStore").ActiveCast | null;
summons: readonly import("./combatStore").CombatSummon[];
areaEffects: readonly import("./combatStore").CombatAreaEffect[];
controlledUntil: number;
schoolLockedUntil: import("./combatStore").CombatState["schoolLockedUntil"];
talentRanks: import("./combatStore").CombatState["talentRanks"];
gearStats: import("./combatStore").CombatState["gearStats"];
specializationId: string | null;
playerAnimationEvent: import("./combatAnimation").CharacterCombatAnimationEvent | null;
};
id: string;
characterId?: string | null;
classId?: string;
level?: number;
actionBindings?: Readonly<Record<string, string | null>>;
abilityIds?: readonly string[];
resourceType?: string;
gcdRemainingMs?: number;
lastAbilityId?: string;
abilityRevision?: number;
name: string;
team: 0 | 1;
role: ArenaRole;
@@ -119,7 +161,7 @@ export interface ArenaActorSnapshot {
maxResource: number;
targetActorId: string | null;
cooldowns: Record<string, number>;
effects: readonly { id: string; remainingMs: number; stacks?: number }[];
effects: readonly { id: string; remainingMs: number; stacks?: number; icon?: string; disposition?: "buff" | "debuff" }[];
}
export interface ArenaDynamicObjectSnapshot {
@@ -130,6 +172,8 @@ export interface ArenaDynamicObjectSnapshot {
}
export interface ArenaAuthoritativeSnapshot {
combatPresentation?: readonly import("./combatPresentation").CombatPresentationEvent[];
combatEvents?: readonly import("./combatEvents").StructuredCombatEvent[];
matchId: string;
tick: number;
serverTime: number;
+66 -2
View File
@@ -1,10 +1,20 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const combatMedia = vi.hoisted(() => ({ ready: false }));
const combatMedia = vi.hoisted(() => ({
ready: false,
fetch: vi.fn(),
entry: vi.fn(() => null as import("./combatPresentation").CombatEffectManifestEntry | null),
}));
vi.mock("./combatMediaPack", async (importOriginal) => ({
...(await importOriginal<typeof import("./combatMediaPack")>()),
combatMediaPackReady: () => combatMedia.ready,
fetchOptionalMediaAsset: combatMedia.fetch,
}));
vi.mock("./combatPresentation", async (importOriginal) => ({
...(await importOriginal<typeof import("./combatPresentation")>()),
combatEffectManifestEntry: combatMedia.entry,
}));
import {
@@ -40,6 +50,8 @@ const fixtureEvent: CombatPresentationEvent = {
afterEach(() => {
combatMedia.ready = false;
combatMedia.fetch.mockReset();
combatMedia.entry.mockReset();
vi.unstubAllGlobals();
});
@@ -69,7 +81,7 @@ describe("combat audio output policy", () => {
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: true,
})).toBe(true);
expect(combatMediaPreloadEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(false);
expect(combatMediaPreloadEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true);
expect(combatMediaPreloadEnabled({
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: true,
@@ -104,4 +116,56 @@ describe("combat audio output policy", () => {
expect(AudioContextConstructor).not.toHaveBeenCalled();
});
it("stays silent without an original sound, even with combat audio enabled", () => {
combatMedia.ready = true;
const AudioContextConstructor = vi.fn();
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
playCombatAudio(fixtureEvent);
expect(AudioContextConstructor).not.toHaveBeenCalled();
expect(combatMedia.fetch).not.toHaveBeenCalled();
});
it.each(["original", "missing-file", "bad-audio"])("uses original audio or silence for %s", async (scenario) => {
vi.resetModules();
const audio = await import("./combatAudio");
const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null };
const audioContext = {
currentTime: 0,
state: "running",
destination: {},
createGain: () => ({
connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), value: 1 },
}),
createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }),
createBufferSource: vi.fn(() => source),
createOscillator: vi.fn(),
decodeAudioData: vi.fn(async () => {
if (scenario === "bad-audio") throw new Error("Invalid audio");
return { duration: 1 };
}),
resume: vi.fn(async () => undefined),
};
vi.stubGlobal("window", { AudioContext: function () { return audioContext; } });
combatMedia.ready = true;
combatMedia.entry.mockReturnValue({
key: "wow335:2061", source: "wow335", spellId: 2061,
sounds: [{ url: "/assets/game/combat-effects/audio/flash-heal.wav" }],
});
combatMedia.fetch.mockResolvedValue(new Response(new Uint8Array([1, 2, 3]), {
status: scenario === "missing-file" ? 404 : 200,
}));
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
audio.playCombatAudio({ ...fixtureEvent, source: "wow335", sourceSpellId: 2061 });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(combatMedia.fetch).toHaveBeenCalledWith("/assets/game/combat-effects/audio/flash-heal.wav");
expect(source.start).toHaveBeenCalledTimes(scenario === "original" ? 1 : 0);
expect(audioContext.createBufferSource).toHaveBeenCalledTimes(scenario === "original" ? 1 : 0);
expect(audioContext.createOscillator).not.toHaveBeenCalled();
audio.resetCombatAudio();
});
});
+1 -62
View File
@@ -4,7 +4,6 @@ import {
type CombatEffectSoundAsset,
type CombatPresentationEvent,
type CombatPresentationSettings,
type CombatPresentationSchool,
} from "./combatPresentation";
import {
combatMediaPackReady,
@@ -147,60 +146,6 @@ function registerVoice(voice: ActiveVoice): () => void {
};
}
const SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationSchool, number>> = {
physical: 115,
arcane: 520,
fire: 190,
frost: 720,
nature: 360,
shadow: 145,
holy: 660,
};
function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
if (
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return;
const audioContext = ensureContext();
if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return;
void audioContext.resume().catch(() => undefined);
const oscillator = audioContext.createOscillator();
const envelope = audioContext.createGain();
const panner = positionPanner(audioContext, event);
const start = audioContext.currentTime;
const isImpact = event.phase === "impact" || event.delivery === "melee";
const duration = event.phase === "cast-start" ? 0.22 : isImpact ? 0.12 : event.phase === "tick" ? 0.1 : 0.18;
const base = SCHOOL_FREQUENCY[event.school];
oscillator.type = event.school === "physical" ? "sawtooth" : event.school === "holy" ? "sine" : "triangle";
oscillator.frequency.setValueAtTime(base * (event.phase === "cast-start" ? 0.7 : 1), start);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(35, base * (isImpact ? 0.42 : 1.55)), start + duration);
envelope.gain.setValueAtTime(0.0001, start);
envelope.gain.exponentialRampToValueAtTime(event.actorGroup === "player" ? 0.12 : 0.085, start + 0.015);
envelope.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(envelope);
envelope.connect(panner);
panner.connect(groupGains[event.actorGroup]);
let stopped = false;
const voice: ActiveVoice = {
group: event.actorGroup,
stop: () => {
if (stopped) return;
stopped = true;
try { oscillator.stop(); } catch { /* already stopped */ }
},
};
const unregister = registerVoice(voice);
oscillator.onended = () => {
unregister();
oscillator.disconnect();
envelope.disconnect();
panner.disconnect();
};
oscillator.start(start);
oscillator.stop(start + duration + 0.02);
}
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return null;
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
@@ -321,13 +266,7 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return;
const sound = weightedSound(event);
if (!sound) {
playSynthesizedCombatCue(event);
return;
}
void playBufferedCombatCue(event, sound).then((played) => {
if (!played) playSynthesizedCombatCue(event);
});
if (sound) void playBufferedCombatCue(event, sound).catch(() => undefined);
}
export function stopCombatAudioGroup(group: CombatActorGroup): void {
+32 -4
View File
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const content = vi.hoisted(() => ({
usable: false,
install: vi.fn(async () => undefined),
install: vi.fn(async (): Promise<void> => undefined),
remove: vi.fn(async () => undefined),
listener: null as (() => void) | null,
snapshot: {
@@ -29,25 +29,53 @@ vi.mock("../content/contentManager", () => ({
import {
combatMediaPackReady,
downloadCombatMediaPack,
ensureCombatMediaPack,
fetchCombatMediaAsset,
removeCombatMediaPack,
} from "./combatMediaPack";
beforeEach(() => {
content.usable = false;
content.install.mockClear();
content.install.mockReset();
content.remove.mockClear();
vi.unstubAllGlobals();
});
describe("optional combat media pack", () => {
it("uses the shared verified content-pack installer only after an explicit request", async () => {
describe("original combat media pack", () => {
it("uses the shared verified content-pack installer", async () => {
const progress = vi.fn();
await expect(downloadCombatMediaPack(progress)).resolves.toEqual({ fileCount: 12 });
expect(content.install).toHaveBeenCalledWith("core:combat-media");
expect(progress).toHaveBeenLastCalledWith({ completed: 4, total: 8 });
});
it("automatically installs missing default media and reuses installed media", async () => {
content.install.mockImplementationOnce(async () => { content.usable = true; });
await expect(ensureCombatMediaPack()).resolves.toBe(true);
await expect(ensureCombatMediaPack()).resolves.toBe(true);
expect(content.install).toHaveBeenCalledTimes(1);
});
it("allows visual fallback after a failed download and retries on the next preparation", async () => {
content.install.mockRejectedValueOnce(new Error("Offline"));
await expect(ensureCombatMediaPack()).resolves.toBe(false);
content.install.mockImplementationOnce(async () => { content.usable = true; });
await expect(ensureCombatMediaPack()).resolves.toBe(true);
expect(content.install).toHaveBeenCalledTimes(2);
});
it("shares an in-flight installation between automatic preparation and a download request", async () => {
let finish!: () => void;
content.install.mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve; }));
const preparation = ensureCombatMediaPack();
const download = downloadCombatMediaPack();
expect(content.install).toHaveBeenCalledTimes(1);
content.usable = true;
finish();
await expect(preparation).resolves.toBe(true);
await expect(download).resolves.toEqual({ fileCount: 12 });
});
it("does not fetch source media while its pack is offloaded", async () => {
const fetch = vi.fn();
vi.stubGlobal("fetch", fetch);
+16 -2
View File
@@ -20,12 +20,13 @@ export interface CombatMediaDownloadResult {
}
type ProgressListener = (progress: CombatMediaDownloadProgress) => void;
let installRequest: Promise<void> | null = null;
function isCombatMediaUrl(value: unknown): value is string {
return typeof value === "string" && value.startsWith("/assets/game/combat-effects/");
}
/** Callers must enforce the player's original-media opt-in before fetching. */
/** Fetch original media after the enabled pack is ready. */
export async function fetchCombatMediaAsset(url: string): Promise<Response> {
if (!isCombatMediaUrl(url)) throw new Error(`Unsupported combat media URL: ${url}`);
return fetchOptionalMediaAsset(url);
@@ -50,7 +51,9 @@ export async function downloadCombatMediaPack(
};
const unsubscribe = subscribeContentManager(publishProgress);
try {
await installContentPack(COMBAT_MEDIA_CONTENT_PACK_ID);
installRequest ??= installContentPack(COMBAT_MEDIA_CONTENT_PACK_ID)
.finally(() => { installRequest = null; });
await installRequest;
publishProgress();
const pack = getContentManagerSnapshot().manifest?.packs.find((candidate) => (
candidate.id === COMBAT_MEDIA_CONTENT_PACK_ID
@@ -61,6 +64,17 @@ export async function downloadCombatMediaPack(
}
}
/** Prepare the default media without blocking gameplay when it is unavailable. */
export async function ensureCombatMediaPack(): Promise<boolean> {
if (combatMediaPackReady()) return true;
try {
await downloadCombatMediaPack();
return combatMediaPackReady();
} catch {
return false;
}
}
export async function removeCombatMediaPack(): Promise<void> {
await removeContentPack(COMBAT_MEDIA_CONTENT_PACK_ID);
}
+5 -4
View File
@@ -23,8 +23,8 @@ describe("combat presentation", () => {
audioEnabled: { enemy: false },
audioVolume: { player: 2, party: -1, enemy: 0.35 },
})).toEqual({
originalMediaEnabled: false,
vfxMode: "generated",
originalMediaEnabled: true,
vfxMode: "original",
vfxQuality: "low",
vfxEnabled: { player: false, party: true, enemy: true },
audioEnabled: { player: true, party: true, enemy: false },
@@ -37,12 +37,13 @@ describe("combat presentation", () => {
}).vfxMode).toBe("original");
expect(normalizeCombatPresentationSettings({
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: false,
vfxMode: "original",
}).vfxMode).toBe("generated");
}).vfxMode).toBe("original");
expect(normalizeCombatPresentationSettings({
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
vfxMode: "hybrid",
}).vfxMode).toBe("generated");
}).vfxMode).toBe("original");
expect(normalizeCombatPresentationSettings(null)).toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
});
+5 -8
View File
@@ -40,8 +40,8 @@ export interface CombatPresentationSettings {
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
originalMediaEnabled: false,
vfxMode: "generated",
originalMediaEnabled: true,
vfxMode: "original",
vfxQuality: "high",
vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }),
audioEnabled: Object.freeze({ player: true, party: true, enemy: true }),
@@ -90,12 +90,9 @@ export function normalizeCombatPresentationSettings(
const originalMediaEnabled = typeof record.originalMediaEnabled === "boolean"
? record.originalMediaEnabled
: fallback.originalMediaEnabled;
const requestedVfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
const vfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
? record.vfxMode
: fallback.vfxMode;
const vfxMode = requestedVfxMode === "original" && !originalMediaEnabled
? "generated"
: requestedVfxMode;
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
? record.vfxQuality
: fallback.vfxQuality;
@@ -219,11 +216,11 @@ function installCombatEffectManifest(manifest: GeneratedCombatEffectManifest): v
export function preloadCombatEffectManifest(): Promise<void> {
if (manifestRequest) return manifestRequest;
if (typeof fetch === "undefined") return Promise.resolve();
if (typeof fetch === "undefined" || !combatMediaPackReady()) return Promise.resolve();
manifestRequest = fetchCombatMediaAsset(COMBAT_MEDIA_MANIFEST_URL)
.then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status))))
.then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest))
.catch(() => undefined);
.catch(() => { manifestRequest = null; });
return manifestRequest;
}
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const media = vi.hoisted(() => ({ ready: false, fetch: vi.fn() }));
vi.mock("./combatMediaPack", () => ({
COMBAT_MEDIA_MANIFEST_URL: "/assets/game/combat-effects/manifest.json",
combatMediaPackReady: () => media.ready,
fetchCombatMediaAsset: media.fetch,
}));
beforeEach(() => {
vi.resetModules();
media.ready = false;
media.fetch.mockReset();
});
describe("original media loading", () => {
it("uses the original default once the pack is ready without changing the saved preference", async () => {
const { DEFAULT_COMBAT_PRESENTATION_SETTINGS: settings, effectiveCombatVfxMode } = await import("./combatPresentation");
expect(effectiveCombatVfxMode(settings)).toBe("generated");
media.ready = true;
expect(effectiveCombatVfxMode(settings)).toBe("original");
expect(effectiveCombatVfxMode({ ...settings, originalMediaEnabled: false })).toBe("generated");
expect(settings.vfxMode).toBe("original");
});
it("does not poison manifest loading before installation or after a failed fetch", async () => {
const { preloadCombatEffectManifest, combatEffectManifestEntry } = await import("./combatPresentation");
await preloadCombatEffectManifest();
expect(media.fetch).not.toHaveBeenCalled();
media.ready = true;
media.fetch.mockRejectedValueOnce(new Error("Offline"));
await preloadCombatEffectManifest();
expect(combatEffectManifestEntry(2061, "wow335")).toBeNull();
const entry = { key: "wow335:2061", source: "wow335", spellId: 2061 };
media.fetch.mockResolvedValueOnce(new Response(JSON.stringify({ schemaVersion: 1, entries: [entry] })));
await preloadCombatEffectManifest();
expect(combatEffectManifestEntry(2061, "wow335")).toEqual(entry);
expect(media.fetch).toHaveBeenCalledTimes(2);
});
});
+50 -11
View File
@@ -330,6 +330,10 @@ export interface DiminishingReturnState {
}
export interface MobCombatState {
/** When the hostile actor is a player, preserve its actual defense and resources. */
readonly characterDefender?: CombatDefenderStats;
readonly characterShield?: number;
readonly characterResources?: Readonly<Partial<Record<ResourceType, number>>>;
readonly id: string;
readonly name: string;
readonly serverEntry: number | null;
@@ -1176,7 +1180,7 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
return "spell";
}
function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
if (/\b(?:fire|flame|immolate|incinerate|searing|lava)\b/.test(searchable)) return "fire";
@@ -1328,7 +1332,7 @@ function resolvePlayerDamageResult(
attackKind,
damageSchool: school,
attacker,
defender: npcCombatDefender(mob, stats.level),
defender: mob.characterDefender ?? npcCombatDefender(mob, stats.level),
rules: immune
? {
...rules,
@@ -1710,6 +1714,15 @@ function talentAdjustedIncomingDamage(amount: number, modifiers: readonly Talent
));
}
/** Renderer-independent player defenses for hostile-player combat adapters. */
export function characterCombatDefender(state: CombatState): CombatDefenderStats {
const stats = applyAuraStats(playerCharacterStats(state.classId, state.level, state.gearStats,
state.raceId, state.secondaryClassId, state.talentModifiers), aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID));
const defender = combatDefenderFromCharacterStats(stats);
const multiplier = talentAdjustedIncomingDamage(10_000, state.talentModifiers) / 10_000;
return { ...defender, damageReductionPercent: (1 - (1 - (defender.damageReductionPercent ?? 0) / 100) * multiplier) * 100 };
}
function normalizedSettings(
current: GameplaySettings,
patch: Partial<GameplaySettings>,
@@ -2534,11 +2547,13 @@ function damageMobInWork(
now,
});
work.auras = [...auraAbsorb.auras];
const characterAbsorbed = Math.min(mob.characterShield ?? 0, auraAbsorb.remainingDamage);
const remainingDamage = Math.max(0, auraAbsorb.remainingDamage - characterAbsorbed);
const adjustedCombatResult = combatResult
? { ...combatResult, finalDamage: Math.max(0, Math.round(auraAbsorb.remainingDamage)) }
? { ...combatResult, finalDamage: Math.max(0, Math.round(remainingDamage)) }
: undefined;
if (adjustedCombatResult) work.combatResults.push(adjustedCombatResult);
const applied = Math.min(mob.health, Math.max(0, Math.round(auraAbsorb.remainingDamage)));
const applied = Math.min(mob.health, Math.max(0, Math.round(remainingDamage)));
recordCombatEventInWork(work, {
occurredAt: now,
kind: "damage",
@@ -2549,7 +2564,7 @@ function damageMobInWork(
rawAmount: combatResult?.rawDamage ?? Math.max(0, Math.round(amount)),
effectiveAmount: applied,
overheal: 0,
absorbed: auraAbsorb.absorbed,
absorbed: auraAbsorb.absorbed + characterAbsorbed,
critical: combatResult?.critical ?? false,
outcome: combatResult?.outcome ?? "hit",
});
@@ -2586,6 +2601,7 @@ function damageMobInWork(
);
work.mobs[id] = {
...engagedMob,
...(mob.characterShield !== undefined ? { characterShield: mob.characterShield - characterAbsorbed } : {}),
health,
dead: killed,
hasLoot,
@@ -2609,7 +2625,9 @@ function damageMobInWork(
combatPhase: killed ? "idle" : "chasing",
homePosition: engagedMob.homePosition ?? getMobPosition(id),
nextAttackAt: killed ? 0 : (mob.nextAttackAt > now ? mob.nextAttackAt : now + 450),
activeCast: killed ? null : engagedMob.activeCast,
activeCast: killed ? null : mob.characterDefender && applied > 0 && engagedMob.activeCast
? { ...engagedMob.activeCast, ...applyWrathCastPushback(engagedMob.activeCast as unknown as ActiveCast, now, `${source.actorId}:${now}`) }
: engagedMob.activeCast,
woundRevision: canPlayWoundAnimation ? mob.woundRevision + 1 : mob.woundRevision,
nextWoundAnimationAt: canPlayWoundAnimation
? now + MOB_WOUND_ANIMATION_COOLDOWN_MS
@@ -2737,19 +2755,19 @@ function applyStatus(
"stun", "fear", "root", "sleep", "blind", "freeze", "charm", "confuse", "knockdown",
]);
const previousDiminishing = mob.controlDiminishingReturns?.[effect.status];
const applications = mob.boss && diminishingStatuses.has(effect.status) && previousDiminishing?.resetAt && previousDiminishing.resetAt > now
const applications = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status) && previousDiminishing?.resetAt && previousDiminishing.resetAt > now
? previousDiminishing.applications
: 0;
const multiplier = mob.boss && diminishingStatuses.has(effect.status)
const multiplier = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status)
? [1, 0.5, 0.25, 0][Math.min(3, applications)]
: 1;
const durationMs = Math.round(baseDurationMs * multiplier);
const controlDiminishingReturns = mob.boss && diminishingStatuses.has(effect.status)
const controlDiminishingReturns = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status)
? {
...mob.controlDiminishingReturns,
[effect.status]: {
applications: Math.min(3, applications + 1),
resetAt: now + 15_000,
resetAt: now + (mob.characterDefender ? durationMs : 0) + 15_000,
},
}
: mob.controlDiminishingReturns;
@@ -4986,6 +5004,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
for (const id of candidates) {
const mob = work.mobs[id];
if (mob?.characterDefender && ability.effects.some((candidate) => candidate.kind === "apply-aura" && candidate.control?.kind === effect.status)) continue;
if (mob) work.mobs[id] = applyStatus(mob, {
...effect,
durationMs: talentAdjustedDuration(effect.durationMs, ability, modifiers),
@@ -5010,6 +5029,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: [targetId ?? PLAYER_AURA_ENTITY_ID];
for (const recipientId of recipients) {
const previousAuras = work.auras;
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
work.auras = [...applyCombatAura(previousAuras, {
definition: auraDefinition,
sourceId: PLAYER_AURA_ENTITY_ID,
@@ -5032,12 +5052,18 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
if (effect.control && work.mobs[recipientId]) {
const durationMs = auraDefinition.durationMs ?? 1_000;
const previousStatuses = work.mobs[recipientId].statuses;
work.mobs[recipientId] = applyStatus(work.mobs[recipientId], {
kind: "status",
status: effect.control.kind as MobStatusKind,
durationMs,
...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }),
}, `aura:${auraDefinition.id}`, now);
if (work.mobs[recipientId].characterDefender) {
const applied = work.mobs[recipientId].statuses.find((status) => status.sourceAbilityId === `aura:${auraDefinition.id}`);
if (work.mobs[recipientId].statuses === previousStatuses) work.auras = [...previousAuras];
else if (applied) work.auras = work.auras.map((aura) => aura.targetId === recipientId && aura.definition.id === auraDefinition.id ? { ...aura, expiresAt: applied.endsAt } : aura);
}
}
}
} else if (effect.kind === "dispel") {
@@ -5140,7 +5166,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource));
const drainAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
if (ability.target === "hostile") {
const returned = effect.burn ? 0 : drainAmount;
let drainedFromPlayers = 0;
let hasPlayerTarget = false;
for (const id of affectedMobIds) {
const mob = work.mobs[id];
if (!mob?.characterResources) continue;
hasPlayerTarget = true;
const current = mob.characterResources[resource] ?? 0;
const requested = effect.percentage ? mob.maxMana * effect.amount / 100 : effect.amount;
const drained = Math.min(current, requested);
drainedFromPlayers += drained;
work.mobs[id] = { ...mob, characterResources: { ...mob.characterResources, [resource]: current - drained } };
if (effect.burn && drained > 0) damageMobInWork(state.classId, work, id, drained, now, castThreatSource, damageSchool);
}
const returned = effect.burn ? 0 : hasPlayerTarget ? drainedFromPlayers : drainAmount;
if (returned > 0) {
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + returned, 0, maximum);
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
+110
View File
@@ -4,6 +4,8 @@ import {
emitControllerToken,
orderedControllerTokenTransitions,
resetControllerState,
setExternalControllerMovement,
interceptControllerInput,
} from "../input/controller";
import {
MAX_MOUSE_DELTA_PER_EVENT,
@@ -23,8 +25,11 @@ import {
keyboardCommandForCode,
queueJumpRequest,
sanitizeMouseDelta,
readInputSnapshot,
type InputActions,
} from "./inputManager";
import { normalizeInputPreferences, replaceInputBinding } from "../input/keybinds";
import { setCapturingInput } from "../input/keybindRuntime";
function stubInputEventTargets(): void {
const windowTarget = new EventTarget();
@@ -59,12 +64,117 @@ function inputActionSpies(blocked = false): InputActions {
}
afterEach(() => {
setCapturingInput(false);
endMouseLookDrag();
clearJumpRequest();
resetControllerState();
vi.unstubAllGlobals();
});
function keyboardEvent(type: "keydown" | "keyup", code: string, modifiers: Partial<KeyboardEvent> = {}) {
const event = new Event(type, { cancelable: true });
Object.defineProperties(event, Object.fromEntries(Object.entries({ code, target: null, ...modifiers }).map(([key, value]) => [key, { value }])));
window.dispatchEvent(event);
return event;
}
describe("remappable gameplay controls", () => {
it("keeps moving forward when a legacy WoW skill also owns W", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.pc["bar:0:0"] = "KeyW";
next.getBindings = (device) => preferences[device];
next.desktopActionBarsActive = () => true;
next.castDesktopSlot = vi.fn();
const dispose = installInput(next);
keyboardEvent("keydown", "KeyW");
expect(readInputSnapshot().moveForward).toBe(1);
expect(next.castDesktopSlot).not.toHaveBeenCalled();
keyboardEvent("keyup", "KeyW");
expect(readInputSnapshot().moveForward).toBe(0);
dispose();
});
it("honors remapped movement and clears held input when a menu opens", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.pc = replaceInputBinding(preferences.pc, "pc", "move-forward", "ArrowUp");
next.getBindings = (device) => preferences[device];
const dispose = installInput(next);
keyboardEvent("keydown", "KeyW"); expect(readInputSnapshot().moveForward).toBe(0);
keyboardEvent("keydown", "ArrowUp");
expect(readInputSnapshot()).toMatchObject({ moveForward: 1, lookY: 0 });
(next.gameplayActionsBlocked as ReturnType<typeof vi.fn>).mockReturnValue(true);
expect(readInputSnapshot().moveForward).toBe(0);
(next.gameplayActionsBlocked as ReturnType<typeof vi.fn>).mockReturnValue(false);
expect(readInputSnapshot().moveForward).toBe(0);
dispose();
});
it("casts remapped bar chords once and gives ground confirmation its context", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.pc["bar:4:11"] = "Ctrl+Shift+KeyQ";
preferences.pc["ground-confirm"] = "Ctrl+Shift+KeyQ";
next.getBindings = (device) => preferences[device]; next.desktopActionBarsActive = () => true; next.castDesktopSlot = vi.fn();
const dispose = installInput(next);
keyboardEvent("keydown", "KeyQ", { ctrlKey: true, shiftKey: true });
keyboardEvent("keydown", "KeyQ", { ctrlKey: true, shiftKey: true, repeat: true });
expect(next.castDesktopSlot).toHaveBeenCalledExactlyOnceWith(4, 11);
(next.groundTargetActive as ReturnType<typeof vi.fn>).mockReturnValue(true);
keyboardEvent("keydown", "KeyQ", { ctrlKey: true, shiftKey: true });
expect(next.confirmGroundTarget).toHaveBeenCalledTimes(1);
expect(next.castDesktopSlot).toHaveBeenCalledTimes(1);
dispose();
});
it("remaps controller axes and buttons with analog movement preserved", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.controller = replaceInputBinding(preferences.controller, "controller", "move-forward", "Axis3-");
preferences.controller = replaceInputBinding(preferences.controller, "controller", "move-right", "Button3");
next.getBindings = (device) => preferences[device];
const dispose = installInput(next);
setExternalControllerMovement({ moveX: 0, moveY: -1, lookX: 0, lookY: 0 });
expect(readInputSnapshot().moveForward).toBe(0);
setExternalControllerMovement({ moveX: 0, moveY: 0, lookX: 0, lookY: -.7 });
expect(readInputSnapshot().moveForward).toBeGreaterThan(.3);
expect(readInputSnapshot().moveForward).toBeLessThan(1);
expect(readInputSnapshot().lookY).toBe(0);
setExternalControllerMovement({ moveX: 0, moveY: 0, lookX: 0, lookY: 0 });
emitControllerToken({ token: "Button3", repeat: false, pressed: true });
expect(readInputSnapshot().moveX).toBe(1);
expect(next.castActionBinding).not.toHaveBeenCalled();
emitControllerToken({ token: "Button3", repeat: false, pressed: false });
expect(readInputSnapshot().moveX).toBe(0);
dispose();
});
it("uses customized controller combinations before their base button", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.controller = replaceInputBinding(preferences.controller, "controller", "jump", "Button8&Button0");
next.getBindings = (device) => preferences[device];
const dispose = installInput(next);
emitControllerToken({ token: "Button8", repeat: false, pressed: true });
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
expect(consumeJumpRequest()).not.toBeNull();
expect(next.castActionBinding).not.toHaveBeenCalled();
expect(next.lootActiveBoss).not.toHaveBeenCalled();
dispose();
});
it("blocks keyboard actions, controller actions and movement throughout capture", () => {
stubInputEventTargets();
const next = inputActionSpies(); const dispose = installInput(next);
setCapturingInput(true);
const captured = vi.fn(() => true); const unsubscribe = interceptControllerInput(captured);
keyboardEvent("keydown", "KeyW"); keyboardEvent("keydown", "KeyI"); keyboardEvent("keydown", "Digit1");
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
emitControllerToken({ token: "Button9", repeat: false, pressed: true });
setExternalControllerMovement({ moveX: 1, moveY: -1, lookX: 0, lookY: 0 });
expect(readInputSnapshot()).toMatchObject({ moveX: 0, moveForward: 0 });
expect(next.castActionBinding).not.toHaveBeenCalled(); expect(next.toggleInventory).not.toHaveBeenCalled(); expect(next.togglePause).not.toHaveBeenCalled();
expect(captured).toHaveBeenCalledTimes(2);
unsubscribe(); dispose();
});
});
describe("gameplay input bindings", () => {
it("consumes custom action keys before movement, menus and legacy action bindings", () => {
stubInputEventTargets();
+102 -133
View File
@@ -2,12 +2,13 @@ import { applyRadialDeadzone, normalizePlanar } from "./inputMath";
import {
CONTROLLER_SYSTEM_BACK_TOKEN,
getControllerMovement,
getControllerHeldTokens,
subscribeControllerToken,
} from "../input/controller";
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent, defaultInputBindings, type InputBindings, type InputDevice } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import type { PartyCommand } from "./partyStore";
import {
actionControlForControllerToken,
actionControlForKeyboardCode,
actionLayerForModifiers,
type ActionBindingControl,
type ActionBindingLayer,
@@ -25,6 +26,10 @@ export interface InputActions {
/** Runs before built-in shortcuts. True consumes the key, including empty custom slots. */
handleCustomKey?: (event: KeyboardEvent) => boolean;
desktopActionBarsActive?: () => boolean;
getBindings?: (device: InputDevice) => InputBindings;
inputSuspended?: () => boolean;
castDesktopSlot?: (bar: number, slot: number) => void;
togglePanel?: (panel: "party" | "boss-guide" | "bindings" | "options" | "keybinds" | "ui-layout") => void;
togglePause: () => void;
toggleMap: () => void;
toggleInventory: () => void;
@@ -150,21 +155,19 @@ export function isMouseLookDragActive(): boolean {
return mouseLookDragActive;
}
const GAMEPLAY_AXIS_KEYS = new Set([
"KeyW", "KeyA", "KeyS", "KeyD",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
]);
export function keyboardAxesForKeys(activeKeys: ReadonlySet<string>): Pick<
InputSnapshot,
"moveX" | "moveForward" | "lookX" | "lookY"
> {
return {
moveX: Number(activeKeys.has("KeyD")) - Number(activeKeys.has("KeyA")),
moveForward: Number(activeKeys.has("KeyW")) - Number(activeKeys.has("KeyS")),
lookX: Number(activeKeys.has("ArrowRight")) - Number(activeKeys.has("ArrowLeft")),
lookY: Number(activeKeys.has("ArrowDown")) - Number(activeKeys.has("ArrowUp")),
};
function inputAxes(value: (id: string) => number): Pick<InputSnapshot, "moveX" | "moveForward" | "lookX" | "lookY"> {
return { moveX: value("move-right") - value("move-left"), moveForward: value("move-forward") - value("move-backward"),
lookX: value("look-right") - value("look-left"), lookY: value("look-down") - value("look-up") };
}
export function keyboardAxesForKeys(activeKeys: ReadonlySet<string>, bindings = defaultInputBindings("pc")) {
return inputAxes((id) => {
const key = bindings[id];
if (!key) return 0;
return Number(activeKeys.has(key));
});
}
function bindingsFor(device: InputDevice): InputBindings {
return actions?.getBindings?.(device) ?? getInputPreferences()[device];
}
export type GameplayInputCommand =
@@ -231,132 +234,88 @@ function runCommand(command: GameplayInputCommand, nextActions: InputActions): v
export function installInput(nextActions: InputActions): () => void {
actions = nextActions;
let controllerL1 = false;
let controllerL2 = false;
const keyboardModifiers = new Set<string>();
const currentActionLayer = () => actionLayerForModifiers(
controllerL1 || keyboardModifiers.has("ShiftLeft") || keyboardModifiers.has("ShiftRight"),
controllerL2 || keyboardModifiers.has("AltLeft") || keyboardModifiers.has("AltRight"),
);
const publishActionLayer = () => nextActions.setActionLayer(currentActionLayer());
const suspended = () => isCapturingInput() || nextActions.inputSuspended?.();
const publishActionLayer = () => {
const held = getControllerHeldTokens();
const layerHeld = (layer: string) => Object.entries(bindingsFor("controller")).some(([id, key]) => {
const parts = key?.split("&").slice(0, -1) ?? [];
return id.startsWith("ability:" + layer + ":") && parts.length > 0 && parts.every((token) => held.has(token));
});
nextActions.setActionLayer(actionLayerForModifiers(layerHeld("secondary") || keyboardModifiers.has("Shift"), layerHeld("tertiary") || keyboardModifiers.has("Alt")));
};
const dispatch = (id: string) => {
if (id.startsWith("ability:")) {
const [, layer, control] = id.split(":");
nextActions.setActionLayer(layer as ActionBindingLayer);
nextActions.castActionBinding(layer as ActionBindingLayer, control as ActionBindingControl);
} else if (id.startsWith("bar:")) {
const [, bar, slot] = id.split(":"); nextActions.castDesktopSlot?.(Number(bar), Number(slot));
} else if (id === "ground-confirm") nextActions.confirmGroundTarget();
else if (id === "ground-cancel") nextActions.cancelGroundTarget();
else if (["party", "boss-guide", "bindings", "options", "keybinds", "ui-layout"].includes(id)) nextActions.togglePanel?.(id as Parameters<NonNullable<InputActions["togglePanel"]>>[0]);
else runCommand(id as GameplayInputCommand, nextActions);
};
const candidates = (device: InputDevice) => INPUT_ACTIONS.filter((entry) => {
if (entry.context === "global" || entry.context === "menu" || entry.context === "ability-editor") return false;
if (entry.context === "ground") return nextActions.groundTargetActive() && !nextActions.gameplayActionsBlocked();
if (entry.id.startsWith("bar:") && (!nextActions.desktopActionBarsActive?.() || Number(entry.id.split(":")[1]) >= 5)) return false;
if (device === "pc" && entry.id.startsWith("ability:") && nextActions.desktopActionBarsActive?.()) return false;
return !nextActions.gameplayActionsBlocked() || (entry.group === "Panels" && (device === "pc" || entry.id === "pause"));
});
const unsubscribeController = subscribeControllerToken(({ token, repeat, pressed }) => {
const isPressed = pressed !== false;
if (token === "Button4" || token === "Button6") {
if (token === "Button4") controllerL1 = isPressed;
else controllerL2 = isPressed;
nextActions.setInputMode("gamepad");
publishActionLayer();
return;
}
if (!isPressed) return;
if (suspended()) return;
publishActionLayer();
if (pressed === false || repeat) return;
nextActions.setInputMode("gamepad");
if (repeat) return;
if (nextActions.groundTargetActive()) {
if (token === "Button0") {
nextActions.confirmGroundTarget();
return;
if (token === CONTROLLER_SYSTEM_BACK_TOKEN) {
if (!nextActions.gameplayActionsBlocked()) {
if (nextActions.groundTargetActive()) nextActions.cancelGroundTarget(); else nextActions.togglePause();
}
if (token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN) {
nextActions.cancelGroundTarget();
return;
}
}
if (
token === "Button0"
&& !nextActions.gameplayActionsBlocked()
&& nextActions.lootActiveBoss()
) return;
const actionControl = actionControlForControllerToken(token);
if (actionControl) {
if (nextActions.gameplayActionsBlocked()) return;
const layer = currentActionLayer();
nextActions.setActionLayer(layer);
nextActions.castActionBinding(layer, actionControl);
return;
}
const command = controllerCommandForToken(token);
if (!command) return;
// Android's system Back is semantic rather than the Circle face button.
// Let the active menu's controller hook own it while a surface is open;
// in live gameplay it opens the pause hub.
if (token === CONTROLLER_SYSTEM_BACK_TOKEN && nextActions.gameplayActionsBlocked()) return;
// Start must remain available in every context so a Thor player can always
// reach or leave the pause hub. Menu components own the other buttons while
// an overlay is visible, preventing casts from firing behind a panel.
if (command === "pause") {
runCommand(command, nextActions);
return;
const bindings = bindingsFor("controller"); const held = getControllerHeldTokens();
const matches = candidates("controller").filter((entry) => controllerBindingMatches(bindings[entry.id], token, held))
.sort((a, b) => Number(b.context === "ground") - Number(a.context === "ground")
|| (bindings[b.id]?.split("&").length ?? 0) - (bindings[a.id]?.split("&").length ?? 0));
for (const entry of matches) {
if (entry.continuous) return;
if (entry.id === "loot") { if (nextActions.lootActiveBoss()) return; else continue; }
dispatch(entry.id); return;
}
if (nextActions.gameplayActionsBlocked()) return;
runCommand(command, nextActions);
});
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches("input, textarea, select, [contenteditable='true']")) return;
if (event.defaultPrevented || target?.closest?.("input, textarea, select, [contenteditable='true']") || suspended()) return;
if (nextActions.handleCustomKey?.(event)) {
event.preventDefault();
event.stopImmediatePropagation();
keys.delete(event.code);
return;
event.preventDefault(); event.stopImmediatePropagation(); keys.delete(event.code); return;
}
if (["ShiftLeft", "ShiftRight", "AltLeft", "AltRight"].includes(event.code)) {
keyboardModifiers.add(event.code);
nextActions.setInputMode("keyboard");
publishActionLayer();
if (!nextActions.gameplayActionsBlocked()) event.preventDefault();
return;
}
if (!nextActions.gameplayActionsBlocked() && GAMEPLAY_AXIS_KEYS.has(event.code)) {
event.preventDefault();
keys.add(event.code);
actions?.setInputMode("keyboard");
}
if (event.repeat) return;
if (nextActions.groundTargetActive() && (event.code === "Escape" || event.code === "Enter")) {
event.preventDefault();
nextActions.setInputMode("keyboard");
if (event.code === "Enter") nextActions.confirmGroundTarget();
else nextActions.cancelGroundTarget();
return;
}
const actionControl = nextActions.desktopActionBarsActive?.() ? null : actionControlForKeyboardCode(event.code);
if (actionControl) {
if (nextActions.gameplayActionsBlocked()) return;
event.preventDefault();
nextActions.setInputMode("keyboard");
const layer = actionLayerForModifiers(event.shiftKey, event.altKey);
nextActions.setActionLayer(layer);
nextActions.castActionBinding(layer, actionControl);
return;
}
const command = keyboardCommandForCode(event.code, event.shiftKey);
if (!command) return;
const partyCommand = command === "party-attack"
|| command === "party-defend"
|| command === "party-stop"
|| command === "party-recall";
// Function keys are browser-reserved on common PC browsers. Keep party
// shortcuts inside the game even while a menu temporarily blocks orders.
if (partyCommand) event.preventDefault();
const overlayCommand = command === "pause"
|| command === "map"
|| command === "inventory"
|| command === "spellbook"
|| command === "talents";
if (overlayCommand) {
runCommand(command, nextActions);
return;
}
if (nextActions.gameplayActionsBlocked()) return;
if (command === "jump") event.preventDefault();
if (event.code === "Tab") event.preventDefault();
runCommand(command, nextActions);
const modifier = { ShiftLeft: "Shift", ShiftRight: "Shift", AltLeft: "Alt", AltRight: "Alt", ControlLeft: "Ctrl", ControlRight: "Ctrl", MetaLeft: "Meta", MetaRight: "Meta" }[event.code];
if (modifier) { keyboardModifiers.add(modifier); publishActionLayer(); return; }
const key = inputKeyForEvent(event);
if (!key) return;
const bindings = bindingsFor("pc");
if (nextActions.gameplayActionsBlocked() && INPUT_ACTIONS.some((entry) => entry.context === "menu" && bindings[entry.id] === key)) return;
const matches = candidates("pc").filter((entry) => bindings[entry.id] === key)
.sort((a, b) => Number(b.context === "ground") - Number(a.context === "ground"));
// Preserve ordinary movement while a modifier is held (e.g. Shift + W).
const continuous = !nextActions.gameplayActionsBlocked() && INPUT_ACTIONS.some((entry) => entry.continuous && (bindings[entry.id] === event.code || bindings[entry.id] === key));
if (continuous) { keys.add(event.code); keys.add(key); event.preventDefault(); nextActions.setInputMode("keyboard"); }
if (!matches.length) return;
const entry = matches[0];
event.preventDefault(); nextActions.setInputMode("keyboard");
if (entry.continuous || event.repeat) return;
dispatch(entry.id);
};
const onKeyUp = (event: KeyboardEvent) => {
keys.delete(event.code);
if (keyboardModifiers.delete(event.code)) publishActionLayer();
for (const key of keys) if (key === event.code || key.endsWith("+" + event.code)) keys.delete(key);
const modifier = { ShiftLeft: "Shift", ShiftRight: "Shift", AltLeft: "Alt", AltRight: "Alt", ControlLeft: "Ctrl", ControlRight: "Ctrl", MetaLeft: "Meta", MetaRight: "Meta" }[event.code];
if (modifier) {
keyboardModifiers.delete(modifier);
for (const key of keys) if (key.split("+").includes(modifier)) keys.delete(key);
}
publishActionLayer();
};
const onMouseMove = (event: MouseEvent) => {
if (mouseLookDragActive) {
@@ -385,8 +344,6 @@ export function installInput(nextActions: InputActions): () => void {
const clear = () => {
keys.clear();
keyboardModifiers.clear();
controllerL1 = false;
controllerL2 = false;
clearJumpRequest();
mouseLookDragActive = false;
previousMousePosition = null;
@@ -418,11 +375,23 @@ export function consumeMouseLook(): readonly [number, number] {
}
export function readInputSnapshot(): InputSnapshot {
const keyboard = keyboardAxesForKeys(keys);
if (isCapturingInput() || actions?.inputSuspended?.() || actions?.gameplayActionsBlocked()) {
keys.clear();
return { moveX: 0, moveForward: 0, lookX: 0, lookY: 0, gamepadActive: false };
}
const keyboard = keyboardAxesForKeys(keys, bindingsFor("pc"));
const controller = getControllerMovement();
const [stickMoveX, stickForward] = applyRadialDeadzone(controller.moveX, -controller.moveY);
const [stickLookX, stickLookY] = applyRadialDeadzone(controller.lookX, controller.lookY, 0.16);
const axes = [controller.moveX, controller.moveY, controller.lookX, controller.lookY];
const held = getControllerHeldTokens(); const bindings = bindingsFor("controller");
const remapped = inputAxes((id) => {
const parts = bindings[id]?.split("&") ?? []; const token = parts.pop();
if (!token || !parts.every((part) => held.has(part))) return 0;
const axis = /^Axis([0-3])([+-])$/.exec(token);
return axis ? Math.max(0, axes[Number(axis[1])] * (axis[2] === "+" ? 1 : -1)) : Number(held.has(token));
});
const [stickMoveX, stickForward] = applyRadialDeadzone(remapped.moveX, remapped.moveForward);
const [stickLookX, stickLookY] = applyRadialDeadzone(remapped.lookX, remapped.lookY, 0.16);
const [moveX, moveForward] = normalizePlanar(
keyboard.moveX + stickMoveX,
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { INPUT_ACTIONS, conflictingInputActions, defaultInputDevice, normalizeInputPreferences, replaceInputBinding } from "../input/keybinds";
import { exportUiPreferences, importUiPreferences, normalizeUiPreferences } from "./uiLayout";
import { profileInputBindings, restoreMovementBindings, withProfileInputBindings } from "./keybindPreferences";
describe("profile keybinds", () => {
it("defaults desktop browsers to PC and mobile/native apps to Controller", () => {
expect(defaultInputDevice(false, false)).toBe("pc");
expect(defaultInputDevice(false, true)).toBe("controller");
expect(defaultInputDevice(true, false)).toBe("controller");
expect(defaultInputDevice(true, true)).toBe("controller");
});
it("migrates older profiles with every action, independent input types and 60 WoW slots", () => {
const preferences = normalizeUiPreferences({ version: 1 });
expect(preferences.keybinds.pc["move-forward"]).toBe("KeyW");
expect(preferences.keybinds.controller["move-forward"]).toBe("Axis1-");
expect(INPUT_ACTIONS.filter((entry) => entry.id.startsWith("bar:"))).toHaveLength(60);
expect(INPUT_ACTIONS.filter((entry) => entry.id.startsWith("ability:"))).toHaveLength(24);
expect(Object.keys(preferences.keybinds.pc)).toHaveLength(INPUT_ACTIONS.length);
preferences.keybinds.pc.jump = null;
expect(preferences.keybinds.controller.jump).toBe("Button10");
});
it("finds an existing W skill conflict and repairs movement without losing assigned skills or templates", () => {
const preferences = normalizeUiPreferences(null);
preferences.current.pc.bars[4].slots[11] = { key: "KeyW", abilityId: "heal" };
preferences.keybinds.pc["move-left"] = null;
preferences.slots.mobile[2] = { name: "Travel", layout: preferences.current.mobile };
const bindings = profileInputBindings(preferences, "pc");
expect(conflictingInputActions(bindings, "pc", "move-forward", "KeyW")).toEqual(["bar:4:11"]);
const repaired = restoreMovementBindings(preferences, "pc");
expect(repaired.current.pc.bars[4].slots[11]).toEqual({ key: null, abilityId: "heal" });
expect(repaired.keybinds.pc["move-left"]).toBe("KeyA");
expect(repaired.slots).toEqual(preferences.slots);
expect(preferences.current.pc.bars[4].slots[11].key).toBe("KeyW");
});
it("supports explicit conflict replacement and persistent unbinding", () => {
const preferences = normalizeUiPreferences(null);
const bindings = profileInputBindings(preferences, "pc");
expect(conflictingInputActions(bindings, "pc", "bar:0:0", "KeyW")).toEqual(["move-forward"]);
const changed = withProfileInputBindings(preferences, "pc", replaceInputBinding(bindings, "pc", "bar:0:0", "KeyW"));
const restored = importUiPreferences(exportUiPreferences(changed));
expect(restored.keybinds.pc["move-forward"]).toBeNull();
expect(restored.current.pc.bars[0].slots[0].key).toBe("KeyW");
expect(restoreMovementBindings(restored, "pc").current.pc.bars[0].slots[0].key).toBeNull();
});
it("uses the loaded template's bar keys and changes only the chosen input type", () => {
let preferences = normalizeUiPreferences(null);
const bindings = profileInputBindings(preferences, "pc");
preferences = withProfileInputBindings(preferences, "pc", replaceInputBinding(bindings, "pc", "bar:4:11", "Ctrl+Shift+KeyQ"));
preferences.current.pc.bars[4].slots[11].key = "KeyZ";
expect(profileInputBindings(preferences, "pc")["bar:4:11"]).toBe("KeyZ");
const next = withProfileInputBindings(preferences, "controller", replaceInputBinding(preferences.keybinds.controller, "controller", "jump", "Button4&Button3"));
expect(next.current.pc).toEqual(preferences.current.pc);
expect(importUiPreferences(exportUiPreferences(next)).keybinds.controller.jump).toBe("Button4&Button3");
});
it("distinguishes contextual defaults and mutually exclusive PC layouts from real collisions", () => {
const preferences = normalizeUiPreferences(null);
for (const device of ["pc", "controller"] as const) {
const bindings = profileInputBindings(preferences, device);
expect(INPUT_ACTIONS.flatMap((entry) => conflictingInputActions(bindings, device, entry.id, bindings[entry.id]))).toEqual([]);
}
expect(conflictingInputActions(preferences.keybinds.controller, "controller", "menu-confirm", "Button2")).toContain("editor-clear");
expect(conflictingInputActions(preferences.keybinds.pc, "pc", "jump", "KeyC")).toContain("companion");
});
it("validates imported keybinds, normalizes chords and retains defaults for corrupt profile fields", () => {
const normalized = normalizeInputPreferences({ pc: { jump: "Shift+Ctrl+KeyQ", inventory: "invalid", pause: null }, controller: { jump: "Button6&Button4&Button3" } });
expect(normalized.pc.jump).toBe("Ctrl+Shift+KeyQ");
expect(normalized.pc.inventory).toBe("KeyI");
expect(normalized.pc.pause).toBeNull();
expect(normalized.controller.jump).toBe("Button4&Button6&Button3");
const invalid = JSON.parse(exportUiPreferences(normalizeUiPreferences(null)));
invalid.keybinds.controller.jump = "Axis99+";
expect(() => importUiPreferences(JSON.stringify(invalid))).toThrow("Invalid keybind");
});
it("requires clearing a modifier's standalone action before using it in a controller chord", () => {
const bindings = normalizeInputPreferences(null).controller;
expect(conflictingInputActions(bindings, "controller", "jump", "Button8&Button0")).toEqual(["companion"]);
const changed = replaceInputBinding(bindings, "controller", "jump", "Button8&Button0");
expect(changed.companion).toBeNull();
expect(changed["ability:primary:face-bottom"]).toBe("Button0");
expect(conflictingInputActions(changed, "controller", "companion", "Button8")).toEqual(["jump"]);
expect(conflictingInputActions(bindings, "controller", "jump", "Button4")).toHaveLength(8);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { type InputBindings, type InputDevice, INPUT_ACTIONS, replaceInputBinding } from "../input/keybinds";
import { type UiPreferences } from "./uiLayout";
/** PC bar keys live with their UI layout so loading a template also loads its keys. */
export function profileInputBindings(preferences: UiPreferences, device: InputDevice): InputBindings {
if (device === "controller") return { ...preferences.keybinds.controller };
const result = { ...preferences.keybinds.pc };
preferences.current.pc.bars.forEach((bar, b) => bar.slots.forEach((slot, s) => { result[`bar:${b}:${s}`] = slot.key; }));
return result;
}
export function withProfileInputBindings(preferences: UiPreferences, device: InputDevice, bindings: InputBindings): UiPreferences {
const layout = preferences.current.pc;
return {
...preferences,
keybinds: { ...preferences.keybinds, [device]: { ...bindings } },
current: device !== "pc" ? preferences.current : { ...preferences.current, pc: {
...layout, bars: layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((slot, s) => ({ ...slot, key: bindings[`bar:${b}:${s}`] ?? null })) })),
} },
};
}
export function restoreMovementBindings(preferences: UiPreferences, device: InputDevice): UiPreferences {
let bindings = profileInputBindings(preferences, device);
for (const entry of INPUT_ACTIONS.filter((entry) => entry.id.startsWith("move-") || entry.id === "jump")) {
bindings = replaceInputBinding(bindings, device, entry.id, entry[device]);
}
return withProfileInputBindings(preferences, device, bindings);
}
+1 -1
View File
@@ -8,7 +8,7 @@ import type { GmReviewTarget } from "./gmContentReview";
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
export type InputMode = "keyboard" | "gamepad";
export type GameMode = "dungeon" | "manastorm";
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "gm" | null;
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | null;
interface PlayerSnapshot {
position: Vector3Tuple;
+2 -2
View File
@@ -37,7 +37,7 @@ describe("personal UI layouts", () => {
layout = bindDesktopSlot(layout, 4, 11, null);
expect(layout.bars[4].slots[11].key).toBeNull();
expect(actionKeyForEvent({ code: "KeyQ", ctrlKey: true, shiftKey: true, altKey: false, metaKey: false })).toBe("Ctrl+Shift+KeyQ");
expect(normalizeActionKey("Escape")).toBeNull();
expect(normalizeActionKey("Escape")).toBe("Escape");
expect(normalizeActionKey("ShiftLeft")).toBeNull();
});
it.each([
@@ -45,7 +45,7 @@ describe("personal UI layouts", () => {
["extra slots", (data: any) => { data.slots.mobile.push(null); }],
["invalid coordinates", (data: any) => { data.current.pc.elements.party.position = { x: "bad", y: 0 }; }],
["invalid bar geometry", (data: any) => { data.current.pc.bars[0].columns = 7; }],
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "Escape"; }],
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "NotAKey"; }],
["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }],
["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }],
])("rejects %s without mutating the original preferences", (_, mutate) => {
+16 -1
View File
@@ -1,4 +1,5 @@
import { normalizeHudPosition, type NormalizedHudPosition } from "./hudLayout";
import { normalizeInputBinding, INPUT_ACTIONS, normalizeInputPreferences, type InputPreferences } from "../input/keybinds";
export type UiDevice = "mobile" | "pc";
export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"];
@@ -31,6 +32,7 @@ export interface UiLayout {
export interface UiTemplate { name: string; layout: UiLayout }
export interface UiPreferences {
version: 1;
keybinds: InputPreferences;
current: Record<UiDevice, UiLayout>;
slots: Record<UiDevice, (UiTemplate | null)[]>;
}
@@ -58,7 +60,7 @@ function record(value: unknown): Record<string, unknown> {
function bounded(value: unknown, fallback: number, min: number, max: number): number {
return typeof value === "number" && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
}
const KEY_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/;
const KEY_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Escape|Backspace|Delete|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/;
export function normalizeActionKey(value: unknown): string | null {
if (typeof value !== "string") return null;
const parts = value.split("+");
@@ -112,6 +114,7 @@ export function normalizeUiPreferences(value: unknown): UiPreferences {
const slots = record(source.slots);
return {
version: 1,
keybinds: normalizeInputPreferences(source.keybinds),
current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
@@ -163,6 +166,18 @@ export function importUiPreferences(text: string): UiPreferences {
}
}
};
if (source.keybinds !== undefined) {
if (!source.keybinds || typeof source.keybinds !== "object" || Array.isArray(source.keybinds)) throw new Error("Invalid keybind configuration.");
for (const device of ["pc", "controller"] as const) {
const raw = record(source.keybinds)[device];
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid keybind configuration.");
const bindings = record(record(source.keybinds)[device]);
for (const entry of INPUT_ACTIONS) {
const key = bindings[entry.id];
if (key !== undefined && key !== null && !normalizeInputBinding(device, key)) throw new Error("Invalid keybind configuration.");
}
}
}
for (const device of UI_DEVICES) {
validateLayout(record(source.current)[device]);
const slots = record(source.slots)[device];
+3
View File
@@ -3,6 +3,7 @@ import { Capacitor } from "@capacitor/core";
import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
import { useShellStore } from "../app/shellStore";
import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
import { publishInputPreferences } from "../input/keybindRuntime";
export function currentUiDevice(): UiDevice {
return Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
@@ -49,6 +50,8 @@ function activateOwner() {
useUiLayoutStore.setState({ ownerId, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
}
activateOwner();
publishInputPreferences(useUiLayoutStore.getState().preferences.keybinds);
useUiLayoutStore.subscribe((state) => publishInputPreferences(state.preferences.keybinds));
useShellStore.subscribe((state, previous) => {
if (state.session !== previous.session) activateOwner();
});
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useRef } from "react";
import type { CharacterProfile } from "../app/types";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useArenaStore } from "./arenaStore";
import type { ArenaActorSnapshot } from "./arenaTypes";
/** Existing character panels read this view; the arena server owns combat. */
export function useArenaCombatView(character: CharacterProfile | null, actor: ArenaActorSnapshot | null) {
const mirroring = useRef(false);
useEffect(() => {
if (!character) return;
const previous = useCombatStore.getState();
const previousParty = usePartyStore.getState();
const previousGame = useGameStore.getState();
useCombatStore.getState().initializeCharacter(character);
usePartyStore.setState({ members: [] });
useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
const unsubscribe = useCombatStore.subscribe((state, before) => {
if (mirroring.current) return;
if (state.actionBindings !== before.actionBindings) {
useArenaStore.getState().intent({ type: "bindings", actionBindings: state.actionBindings });
useShellStore.getState().saveActiveActionBindings(state.actionBindings);
}
if (state.settings !== before.settings) useShellStore.getState().saveActiveSettings(state.settings);
});
return () => {
unsubscribe();
useCombatStore.setState(previous, true);
usePartyStore.setState(previousParty, true);
useGameStore.setState(previousGame, true);
};
}, [character?.id]);
useEffect(() => {
if (!actor) return;
mirroring.current = true;
const runtime = actor.combat;
useCombatStore.setState({ health: actor.health, maxHealth: actor.maxHealth,
resource: actor.resource, maxResource: actor.maxResource, level: actor.level,
...(actor.actionBindings ? { actionBindings: actor.actionBindings } : {}),
...(runtime ? { resourcePools: runtime.resourcePools, maxResourcePools: runtime.maxResourcePools,
deathKnightRunes: runtime.runes, comboPoints: runtime.comboPoints, comboTargetId: runtime.comboTargetId,
shield: runtime.shield, activeCast: runtime.activeCast, talentRanks: runtime.talentRanks,
gearStats: runtime.gearStats, specializationId: runtime.specializationId } : {}) });
mirroring.current = false;
}, [actor]);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useRef } from "react";
import type { CharacterProfile } from "../app/types";
import { controlledArenaActor } from "./arenaPlayer";
import { useArenaStore } from "./arenaStore";
import { createArenaCharacterKit } from "./arenaCharacter";
import { actionBindingId, type ActionBindingLayer } from "./actionBindings";
import { activeUiLayout, currentUiDevice } from "./uiLayoutStore";
import { actionKeyForEvent, desktopSlotForKey } from "./uiLayout";
import { endMouseLookDrag, installInput, isDesktopCameraLookPointer } from "./inputManager";
import { useGameStore } from "./store";
export function useArenaInput(options: {
character: CharacterProfile | null;
blocked: boolean;
cast: (id: string) => void;
togglePause: () => void;
setLayer: (layer: ActionBindingLayer) => void;
setInputMode: (mode: "keyboard" | "gamepad") => void;
groundTargetActive: () => boolean;
confirmGroundTarget: () => void;
cancelGroundTarget: () => void;
}) {
const current = useRef(options);
current.current = options;
useEffect(() => {
const cycle = (direction: 1 | -1, friendly: boolean) => {
const state = useArenaStore.getState();
const actor = controlledArenaActor(state.snapshot, current.current.character);
if (!actor) return;
const targets = state.snapshot!.actors.filter((candidate) => candidate.alive && (friendly ? candidate.team === actor.team : candidate.team !== actor.team));
const index = targets.findIndex((candidate) => candidate.id === actor.targetActorId);
const selected = targets[(index + direction + targets.length) % targets.length];
if (selected) state.intent({ type: "target", targetActorId: selected.id });
};
const noop = () => {};
const uninstall = installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
castDesktopSlot: (bar, slot) => {
const id = activeUiLayout().bars[bar]?.slots[slot]?.abilityId;
if (id) current.current.cast(id);
},
handleCustomKey: (event) => {
if (current.current.blocked || currentUiDevice() !== "pc") return false;
const slot = desktopSlotForKey(activeUiLayout(), actionKeyForEvent(event));
if (!slot) return false;
if (!event.repeat && slot.abilityId) current.current.cast(slot.abilityId);
current.current.setInputMode("keyboard");
return true;
},
togglePause: () => current.current.togglePause(),
toggleMap: () => useGameStore.getState().toggleOverlay("map"),
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
toggleSpellbook: () => useGameStore.getState().toggleOverlay("spellbook"),
toggleTalents: () => useGameStore.getState().toggleOverlay("talents"),
togglePanel: (panel) => useGameStore.getState().toggleOverlay(panel),
lootActiveBoss: () => false,
castActionBinding: (layer, control) => {
const character = current.current.character;
if (!character) return;
const actor = controlledArenaActor(useArenaStore.getState().snapshot, character);
const bindings = actor?.actionBindings ?? createArenaCharacterKit(character).actionBindings;
const id = bindings[actionBindingId(layer, control)];
if (id) current.current.cast(id);
},
cycleTarget: (direction) => cycle(direction, false),
cyclePartyTarget: (direction) => cycle(direction, true),
setPartyCommand: (command) => useArenaStore.getState().intent({ type: "command", command }),
recallParty: () => useArenaStore.getState().intent({ type: "command", command: "recall" }),
clearTarget: () => useArenaStore.getState().intent({ type: "target", targetActorId: "" }),
groundTargetActive: () => current.current.groundTargetActive(),
confirmGroundTarget: () => current.current.confirmGroundTarget(),
cancelGroundTarget: () => current.current.cancelGroundTarget(),
gameplayActionsBlocked: () => current.current.blocked,
setCameraLookActive: noop,
setInputMode: (mode) => current.current.setInputMode(mode),
setActionLayer: (layer) => current.current.setLayer(layer),
});
const stopMovement = () => {
endMouseLookDrag();
useArenaStore.getState().intent({ type: "movement", movement: { x: 0, z: 0 } });
};
const mouseUp = (event: MouseEvent) => { if (isDesktopCameraLookPointer("mouse", event.button)) endMouseLookDrag(); };
window.addEventListener("mouseup", mouseUp, true);
window.addEventListener("blur", stopMovement);
return () => {
window.removeEventListener("mouseup", mouseUp, true);
window.removeEventListener("blur", stopMovement);
stopMovement();
uninstall();
};
}, []);
useEffect(() => {
if (options.blocked) {
endMouseLookDrag();
useArenaStore.getState().intent({ type: "movement", movement: { x: 0, z: 0 } });
}
}, [options.blocked]);
}
+10 -1
View File
@@ -1,4 +1,5 @@
import { Capacitor } from "@capacitor/core";
import { controllerModifierTokens } from "./keybindRuntime";
export interface ControllerTokenEvent {
token: string;
@@ -29,6 +30,7 @@ const GAMEPAD_DISCOVERY_INTERVAL_MS = 500;
const REPEATABLE_BUTTON_TOKENS = new Set(["Button12", "Button13", "Button14", "Button15"]);
const listeners = new Set<TokenListener>();
const interceptors = new Set<(event: ControllerTokenEvent) => boolean>();
const movementListeners = new Set<MovementListener>();
const repeatAt = new Map<string, number>();
const lastNativeTokenAt = new Map<string, number>();
@@ -74,7 +76,7 @@ function tokenSetsMatch(left: ReadonlySet<string>, right: ReadonlySet<string>):
}
function isLayerModifier(token: string): boolean {
return token === "Button4" || token === "Button6";
return controllerModifierTokens().has(token);
}
/**
@@ -106,9 +108,16 @@ export function emitControllerToken(event: ControllerTokenEvent): void {
};
if (normalized.pressed) heldTokens.add(normalized.token);
else heldTokens.delete(normalized.token);
for (const intercept of interceptors) if (intercept(normalized)) return;
for (const listener of listeners) listener(normalized);
}
export function getControllerHeldTokens(): ReadonlySet<string> { return heldTokens; }
export function interceptControllerInput(intercept: (event: ControllerTokenEvent) => boolean): () => void {
interceptors.add(intercept);
return () => { interceptors.delete(intercept); };
}
export function subscribeControllerToken(listener: TokenListener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
+21
View File
@@ -0,0 +1,21 @@
import { normalizeInputPreferences, type InputPreferences } from "./keybinds";
// Kept independent of React, account storage and gameplay stores. The profile store
// publishes changes here so shell menus, native input and the simulation agree.
let preferences = normalizeInputPreferences(null);
let capturing = false;
const listeners = new Set<() => void>();
export function getInputPreferences(): InputPreferences { return preferences; }
export function publishInputPreferences(next: InputPreferences): void {
if (preferences === next) return;
preferences = next;
for (const listener of listeners) listener();
}
export function subscribeInputPreferences(listener: () => void): () => void {
listeners.add(listener); return () => { listeners.delete(listener); };
}
export function isCapturingInput(): boolean { return capturing; }
export function setCapturingInput(value: boolean): void { capturing = value; }
export function controllerModifierTokens(): Set<string> {
return new Set(Object.values(preferences.controller).flatMap((key) => key?.split("&").slice(0, -1) ?? []));
}
+165
View File
@@ -0,0 +1,165 @@
import { ACTION_CONTROLS, ACTION_CONTROL_DEFINITIONS, ACTION_LAYERS } from "../game/actionBindings";
export type InputDevice = "pc" | "controller";
export function defaultInputDevice(native: boolean, mobile: boolean): InputDevice { return native || mobile ? "controller" : "pc"; }
export type InputContext = "game" | "ground" | "menu" | "global" | "ability-editor";
export interface InputActionDefinition {
id: string;
label: string;
group: string;
context: InputContext;
pc: string | null;
controller: string | null;
continuous?: boolean;
}
const action = (id: string, label: string, group: string, pc: string | null, controller: string | null,
context: InputContext = "game", continuous = false): InputActionDefinition => ({ id, label, group, pc, controller, context, continuous });
export const INPUT_ACTIONS: readonly InputActionDefinition[] = [
action("move-forward", "Move forward", "Movement & camera", "KeyW", "Axis1-", "game", true),
action("move-backward", "Move backward", "Movement & camera", "KeyS", "Axis1+", "game", true),
action("move-left", "Strafe left", "Movement & camera", "KeyA", "Axis0-", "game", true),
action("move-right", "Strafe right", "Movement & camera", "KeyD", "Axis0+", "game", true),
action("jump", "Jump", "Movement & camera", "Space", "Button10"),
action("look-up", "Look up", "Movement & camera", "ArrowUp", "Axis3-", "game", true),
action("look-down", "Look down", "Movement & camera", "ArrowDown", "Axis3+", "game", true),
action("look-left", "Look left", "Movement & camera", "ArrowLeft", "Axis2-", "game", true),
action("look-right", "Look right", "Movement & camera", "ArrowRight", "Axis2+", "game", true),
action("target-next", "Next enemy", "Targeting & interaction", "Tab", "Button11"),
action("target-previous", "Previous enemy", "Targeting & interaction", "Shift+Tab", null),
action("party-next", "Next party member / self", "Targeting & interaction", null, "Button13"),
action("party-previous", "Previous party member / self", "Targeting & interaction", null, "Button12"),
action("target-clear", "Clear target", "Targeting & interaction", null, null),
// Contextual interaction uses A before its ability only when there is something to use/loot.
action("loot", "Interact / loot", "Targeting & interaction", "KeyE", "Button0"),
action("ground-confirm", "Confirm ground spell", "Ground targeting", "Enter", "Button0", "ground"),
action("ground-cancel", "Cancel ground spell", "Ground targeting", "Escape", "Button1", "ground"),
action("party-attack", "Party: attack", "Party commands", "F1", null),
action("party-defend", "Party: defend", "Party commands", "F2", null),
action("party-stop", "Party: stop", "Party commands", "F3", null),
action("party-recall", "Party: recall", "Party commands", "F4", null),
action("pause", "Pause / resume", "Panels", "Escape", "Button9"),
action("map", "Dungeon map", "Panels", "KeyM", null),
action("inventory", "Inventory & equipment", "Panels", "KeyI", null),
action("spellbook", "Spellbook", "Panels", "KeyP", null),
action("talents", "Talents / skills", "Panels", "KeyN", null),
action("party", "Party management", "Panels", null, null),
action("boss-guide", "Boss guide", "Panels", null, null),
action("bindings", "Ability assignments", "Panels", null, null),
action("options", "Options", "Panels", null, null),
action("keybinds", "Keybinds", "Panels", null, null),
action("ui-layout", "Edit UI & templates", "Panels", null, null),
action("companion", "Companion display", "Panels", "KeyC", "Button8", "global"),
action("menu-up", "Menu: up", "Menu navigation", "ArrowUp", "Button12", "menu"),
action("menu-down", "Menu: down", "Menu navigation", "ArrowDown", "Button13", "menu"),
action("menu-left", "Menu: left", "Menu navigation", "ArrowLeft", "Button14", "menu"),
action("menu-right", "Menu: right", "Menu navigation", "ArrowRight", "Button15", "menu"),
action("menu-confirm", "Menu: confirm", "Menu navigation", "Enter", "Button0", "menu"),
action("menu-back", "Menu: back", "Menu navigation", "Escape", "Button1", "menu"),
action("menu-up-alt", "Menu: up (alternate)", "Menu navigation", "KeyW", "Axis1-", "menu"),
action("menu-down-alt", "Menu: down (alternate)", "Menu navigation", "KeyS", "Axis1+", "menu"),
action("menu-left-alt", "Menu: left (alternate)", "Menu navigation", "KeyA", "Axis0-", "menu"),
action("menu-right-alt", "Menu: right (alternate)", "Menu navigation", "KeyD", "Axis0+", "menu"),
action("menu-confirm-alt", "Menu: confirm (alternate)", "Menu navigation", "Space", null, "menu"),
action("menu-back-alt", "Menu: back (alternate)", "Menu navigation", "Backspace", null, "menu"),
action("editor-layer-2", "Ability editor: preview layer 2", "Ability editor", null, "Button4", "ability-editor"),
action("editor-layer-3", "Ability editor: preview layer 3", "Ability editor", null, "Button6", "ability-editor"),
action("editor-clear", "Ability editor: clear slot", "Ability editor", null, "Button2", "ability-editor"),
action("editor-reset", "Ability editor: restore layer", "Ability editor", null, "Button3", "ability-editor"),
...ACTION_LAYERS.flatMap((layer, index) => ACTION_CONTROLS.map((control) => {
const definition = ACTION_CONTROL_DEFINITIONS[control];
return action(`ability:${layer}:${control}`, `${["Base", "Layer 2", "Layer 3"][index]} · ${definition.label}`,
"Ability slots", `${["", "Shift+", "Alt+"][index]}${definition.keyboardCode}`,
`${["", "Button4&", "Button6&"][index]}${definition.gamepadToken}`);
})),
...Array.from({ length: 60 }, (_, index) => action(`bar:${Math.floor(index / 12)}:${index % 12}`,
`Bar ${Math.floor(index / 12) + 1} · Button ${index % 12 + 1}`, "WoW action bars", null, null)),
];
export const INPUT_ACTION_BY_ID = Object.fromEntries(INPUT_ACTIONS.map((entry) => [entry.id, entry]));
export type InputBindings = Record<string, string | null>;
export interface InputPreferences { pc: InputBindings; controller: InputBindings }
const PC_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Escape|Backspace|Delete|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/;
const CONTROLLER_TOKEN = /^(?:Button(?:[0-9]|[12][0-9]|3[01])|Axis[0-3][+-])$/;
export function normalizeInputBinding(device: InputDevice, value: unknown): string | null {
if (typeof value !== "string" || value.length > 100) return null;
if (device === "pc") {
const parts = value.split("+"); const code = parts.pop()!;
if (!PC_CODE.test(code) || parts.some((part) => !["Ctrl", "Alt", "Shift", "Meta"].includes(part))) return null;
return [...["Ctrl", "Alt", "Shift", "Meta"].filter((part) => parts.includes(part)), code].join("+");
}
const parts = value.split("&"); const token = parts.pop()!;
if (!CONTROLLER_TOKEN.test(token) || parts.length > 3 || parts.some((part) => !/^Button(?:[0-9]|[12][0-9]|3[01])$/.test(part) || part === token)) return null;
return [...new Set(parts)].sort().concat(token).join("&");
}
export function inputKeyForEvent(event: Pick<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey">): string | null {
return normalizeInputBinding("pc", [event.ctrlKey && "Ctrl", event.altKey && "Alt", event.shiftKey && "Shift", event.metaKey && "Meta", event.code].filter(Boolean).join("+"));
}
export function defaultInputBindings(device: InputDevice): InputBindings {
return Object.fromEntries(INPUT_ACTIONS.map((entry) => [entry.id, entry[device]]));
}
export function normalizeInputPreferences(value: unknown): InputPreferences {
const source = value && typeof value === "object" ? value as Partial<InputPreferences> : {};
const result = { pc: defaultInputBindings("pc"), controller: defaultInputBindings("controller") };
for (const device of ["pc", "controller"] as const) {
const bindings = source[device];
if (!bindings || typeof bindings !== "object" || Array.isArray(bindings)) continue;
for (const entry of INPUT_ACTIONS) {
if (!Object.hasOwn(bindings, entry.id)) continue;
result[device][entry.id] = bindings[entry.id] === null ? null
: normalizeInputBinding(device, bindings[entry.id]) ?? result[device][entry.id];
}
}
return result;
}
const PAD_LABELS: Record<string, string> = {
Button0: "A / Cross", Button1: "B / Circle", Button2: "X / Square", Button3: "Y / Triangle",
Button4: "LB / L1", Button5: "RB / R1", Button6: "LT / L2", Button7: "RT / R2",
Button8: "Select / View", Button9: "Start / Menu", Button10: "L3", Button11: "R3",
Button12: "D-pad up", Button13: "D-pad down", Button14: "D-pad left", Button15: "D-pad right",
"Axis0-": "Left stick left", "Axis0+": "Left stick right", "Axis1-": "Left stick up", "Axis1+": "Left stick down",
"Axis2-": "Right stick left", "Axis2+": "Right stick right", "Axis3-": "Right stick up", "Axis3+": "Right stick down",
};
export function inputBindingLabel(device: InputDevice, binding: string | null | undefined): string {
if (!binding) return "Unbound";
if (device === "controller") return binding.split("&").map((part) => PAD_LABELS[part] ?? part).join(" + ");
return binding.replace(/Key|Digit/g, "").replace("Minus", "−").replace("Equal", "=").replace("Numpad", "Num ");
}
export const CONTROLLER_BINDING_CHOICES = Object.keys(PAD_LABELS);
/** Ground targeting and menus intentionally reuse gameplay buttons in separate contexts. */
export function bindingContextsOverlap(a: string, b: string, device: InputDevice): boolean {
// Contextual A interaction falls through to its base ability when nothing can be used.
if (device === "controller" && [a, b].includes("loot") && [a, b].includes("ability:primary:face-bottom")) return false;
const left = INPUT_ACTION_BY_ID[a]; const right = INPUT_ACTION_BY_ID[b];
if (!left || !right) return false;
// The PC's legacy ability slots and WoW bars are alternative layouts.
if (device === "pc" && ((a.startsWith("bar:") && b.startsWith("ability:")) || (b.startsWith("bar:") && a.startsWith("ability:")))) return false;
return left.context === right.context || left.context === "global" || right.context === "global"
|| [left.context, right.context].every((context) => context === "menu" || context === "ability-editor");
}
export function conflictingInputActions(bindings: InputBindings, device: InputDevice, id: string, key: string | null): string[] {
if (!key) return [];
const collides = (other: string | null | undefined) => {
if (!other) return false;
if (key === other) return true;
if (device !== "controller") return false;
const left = key.split("&"); const right = other.split("&");
// A standalone command on a chord's modifier would fire before its final
// button (e.g. View would open the companion before View + A could jump).
// Sharing the final button is intentional: the more specific chord wins.
return left.every((token) => right.slice(0, -1).includes(token))
|| right.every((token) => left.slice(0, -1).includes(token));
};
return INPUT_ACTIONS.filter((entry) => entry.id !== id && collides(bindings[entry.id]) && bindingContextsOverlap(id, entry.id, device)).map((entry) => entry.id);
}
export function replaceInputBinding(bindings: InputBindings, device: InputDevice, id: string, key: string | null): InputBindings {
const next = { ...bindings, [id]: key };
for (const conflict of conflictingInputActions(bindings, device, id, key)) next[conflict] = null;
return next;
}
export function controllerBindingMatches(binding: string | null | undefined, token: string, held: ReadonlySet<string>): boolean {
if (!binding) return false;
const parts = binding.split("&");
return parts.pop() === token && parts.every((part) => held.has(part));
}
+8
View File
@@ -0,0 +1,8 @@
import { useSyncExternalStore } from "react";
import { getInputPreferences, subscribeInputPreferences } from "./keybindRuntime";
import { inputBindingLabel, type InputDevice } from "./keybinds";
export function useInputBindingLabels() {
const preferences = useSyncExternalStore(subscribeInputPreferences, getInputPreferences, getInputPreferences);
return (device: InputDevice, id: string) => inputBindingLabel(device, preferences[device][id]);
}
+20 -17
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CONTROLLER_SYSTEM_BACK_TOKEN, subscribeControllerToken } from "./controller";
import { CONTROLLER_SYSTEM_BACK_TOKEN, getControllerHeldTokens, subscribeControllerToken } from "./controller";
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "./keybinds";
import { getInputPreferences, isCapturingInput } from "./keybindRuntime";
export type MenuDirection = "up" | "down" | "left" | "right";
@@ -17,7 +19,8 @@ interface MenuControllerOptions {
}
export function isControllerMenuBackToken(token: string): boolean {
return token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN;
const bindings = getInputPreferences().controller;
return token === CONTROLLER_SYSTEM_BACK_TOKEN || ["menu-back", "menu-back-alt"].some((id) => controllerBindingMatches(bindings[id], token, getControllerHeldTokens()));
}
function nextIndex(index: number, direction: MenuDirection, count: number, columns: number): number {
@@ -63,14 +66,15 @@ export function useMenuController(actions: MenuAction[], options: MenuController
const interactiveTarget = event.target instanceof Element
? event.target.closest("button, input, select, textarea, a[href], [contenteditable='true'], [role='button'], [role='link']")
: null;
if (event.repeat || interactiveTarget) return;
const key = event.key.toLowerCase();
if (["arrowup", "w"].includes(key)) move("up");
else if (["arrowdown", "s"].includes(key)) move("down");
else if (["arrowleft", "a"].includes(key)) move("left");
else if (["arrowright", "d"].includes(key)) move("right");
else if (key === "enter" || key === " ") confirm();
else if (key === "escape" || key === "backspace") backRef.current?.();
const textTarget = event.target instanceof Element ? event.target.closest("input, select, textarea, [contenteditable='true']") : null;
if (event.repeat || event.defaultPrevented || textTarget || isCapturingInput()) return;
// Keep native activation/focus navigation on focused controls.
if (interactiveTarget && ["Enter", "Space", "Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code)) return;
const key = inputKeyForEvent(event);
const command = key && INPUT_ACTIONS.find((entry) => entry.context === "menu" && getInputPreferences().pc[entry.id] === key)?.id.replace(/-alt$/, "");
if (["menu-up", "menu-down", "menu-left", "menu-right"].includes(command || "")) move(command!.slice(5) as MenuDirection);
else if (command === "menu-confirm") confirm();
else if (command === "menu-back") backRef.current?.();
else return;
event.preventDefault();
};
@@ -79,14 +83,13 @@ export function useMenuController(actions: MenuAction[], options: MenuController
}, [confirm, move]);
useEffect(() => subscribeControllerToken(({ token, repeat, pressed }) => {
if (pressed === false) return;
const direction: MenuDirection | null = token === "Button12" || token === "Axis1-" ? "up"
: token === "Button13" || token === "Axis1+" ? "down"
: token === "Button14" || token === "Axis0-" ? "left"
: token === "Button15" || token === "Axis0+" ? "right"
: null;
if (pressed === false || isCapturingInput()) return;
const bindings = getInputPreferences().controller;
const command = INPUT_ACTIONS.filter((entry) => entry.context === "menu" && controllerBindingMatches(bindings[entry.id], token, getControllerHeldTokens()))
.sort((a, b) => (bindings[b.id]?.length ?? 0) - (bindings[a.id]?.length ?? 0))[0]?.id.replace(/-alt$/, "");
const direction = ["menu-up", "menu-down", "menu-left", "menu-right"].includes(command) ? command.slice(5) as MenuDirection : null;
if (direction) move(direction);
else if (!repeat && token === "Button0") confirm();
else if (!repeat && command === "menu-confirm") confirm();
else if (!repeat && isControllerMenuBackToken(token)) backRef.current?.();
}), [confirm, move]);
+6 -1
View File
@@ -12,6 +12,8 @@ import {
type CompanionSnapshot,
type DualScreenMessage,
} from "./dualScreenSync";
import { normalizeInputPreferences } from "../input/keybinds";
import { publishInputPreferences } from "../input/keybindRuntime";
import { useForcedThorDisplays } from "./useThorDualScreen";
export function BottomDisplayApp() {
@@ -44,7 +46,10 @@ export function BottomDisplayApp() {
} satisfies DualScreenMessage))
: () => undefined;
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "app-state") setSnapshot(event.data.snapshot);
if (event.data.type === "app-state") {
setSnapshot(event.data.snapshot);
if (event.data.snapshot.keybinds) publishInputPreferences(normalizeInputPreferences(event.data.snapshot.keybinds));
}
else if (event.data.type === "authoritative-ready") ready();
};
window.addEventListener("pageshow", ready);
+4
View File
@@ -1,3 +1,5 @@
import { getInputPreferences } from "../input/keybindRuntime";
import type { InputPreferences } from "../input/keybinds";
import { classById, type GenderId, type RaceId } from "../app/characterCatalog";
import { useGameStore } from "../game/store";
import type { GameMode, GameOverlay } from "../game/store";
@@ -121,6 +123,7 @@ export interface CompanionGameSnapshot {
}
export interface CompanionSnapshot {
keybinds?: InputPreferences;
phase: ShellPhase;
session: PlayerSession | null;
characters: CharacterProfile[];
@@ -239,6 +242,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
}];
});
return {
keybinds: getInputPreferences(),
phase: shell.phase,
session: shell.session,
characters: shell.characters,
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useMemo, useRef, type RefObject } from "react";
import { useFrame } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import { Group, Plane, Raycaster, Vector2, Vector3 } from "three";
import { CombatEffects } from "./CombatEffects";
import { emitCombatPresentation } from "../game/combatPresentation";
import { useArenaStore } from "../game/arenaStore";
import type { ArenaAuthoritativeSnapshot, Vec3Tuple } from "../game/arenaTypes";
export function ArenaCombatEffects({ snapshot, actorId }: { snapshot: ArenaAuthoritativeSnapshot; actorId?: string }) {
const seen = useRef(new Set<number>());
useEffect(() => {
const team = snapshot.actors.find((actor) => actor.id === actorId)?.team;
for (const event of snapshot.combatPresentation ?? []) {
if (seen.current.has(event.id)) continue;
seen.current.add(event.id);
const source = snapshot.actors.find((actor) => actor.id === event.sourceActorId);
emitCombatPresentation({ ...event, occurredAt: Date.now(),
actorGroup: event.sourceActorId === actorId ? "player" : source?.team === team ? "party" : "enemy" });
}
if (seen.current.size > 500) seen.current = new Set((snapshot.combatPresentation ?? []).map((event) => event.id));
}, [snapshot, actorId]);
return <>
<CombatEffects active />
{snapshot.actors.flatMap((actor) => (actor.combat?.summons ?? []).map((summon) => <group key={summon.id} name={`arena-summon:${summon.id}`} position={summon.position}
onPointerDown={(event) => { if (event.button === 0) { event.stopPropagation(); useArenaStore.getState().intent({ type: "target", targetActorId: summon.id }); } }}>
<mesh position={[0, .55, 0]} castShadow>{summon.kind === "totem" ? <cylinderGeometry args={[.22, .38, 1.1, 8]} /> : <capsuleGeometry args={[.32, .55, 5, 10]} />}
<meshStandardMaterial color={actor.team === snapshot.actors.find((entry) => entry.id === actorId)?.team ? "#89bbbd" : "#c36966"} emissive={summon.kind === "totem" ? "#1f4939" : "#000000"} /></mesh>
<Html center position={[0, 1.3, 0]} distanceFactor={12}><span className="arena-summon-name">{summon.name}</span></Html>
</group>))}
{snapshot.actors.flatMap((actor) => (actor.combat?.areaEffects ?? []).map((area) => <mesh key={area.id} position={[area.position[0], area.position[1] + .04, area.position[2]]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[Math.max(.1, area.radius - .12), area.radius, 48]} /><meshBasicMaterial color={actor.team === snapshot.actors.find((entry) => entry.id === actorId)?.team ? "#79d7e8" : "#ed685c"} transparent opacity={.6} depthWrite={false} />
</mesh>))}
</>;
}
export function ArenaGroundTarget({ environment, position, radius, origin, controller }: {
environment: RefObject<Group | null>; position: RefObject<Vec3Tuple>; radius: number; origin: Vec3Tuple; controller: boolean;
}) {
const ring = useRef<Group>(null);
const ray = useMemo(() => new Raycaster(), []);
const plane = useMemo(() => new Plane(new Vector3(0, 1, 0)), []);
const point = useMemo(() => new Vector3(), []);
const center = useMemo(() => new Vector2(), []);
useFrame(({ camera, pointer }) => {
ray.setFromCamera(controller ? center : pointer, camera);
const hit = environment.current ? ray.intersectObject(environment.current, true).find((entry) => Math.abs(entry.point.y - origin[1]) < 6) : null;
plane.constant = -origin[1];
if (hit) point.copy(hit.point);
else if (!ray.ray.intersectPlane(plane, point)) return;
position.current = [point.x, point.y, point.z];
ring.current?.position.set(point.x, point.y + .06, point.z);
});
return <group ref={ring}><mesh rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[Math.max(.1, radius - .12), radius, 48]} /><meshBasicMaterial color="#81edbd" transparent opacity={.9} depthWrite={false} /></mesh></group>;
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useMemo } from "react";
import { BufferGeometry, Float32BufferAttribute, DoubleSide } from "three";
import geometry from "../../server/generated/arena-geometry.json" with { type: "json" };
/** The fallback draws the same surfaces used by the authoritative simulation. */
export default function ArenaFallbackStage({ stageId }: { stageId: string }) {
const meshes = useMemo(() => {
const data = (geometry as Record<string, { navigation: number[]; collision: number[] }>)[stageId];
return ["navigation", "collision"].map((kind) => {
const mesh = new BufferGeometry();
mesh.setAttribute("position", new Float32BufferAttribute(data?.[kind as "navigation" | "collision"] ?? [], 3));
mesh.computeVertexNormals();
return mesh;
});
}, [stageId]);
useEffect(() => () => meshes.forEach((mesh) => mesh.dispose()), [meshes]);
return <group>
<mesh geometry={meshes[0]} position={[0, -.025, 0]} receiveShadow><meshStandardMaterial color="#536452" side={DoubleSide} roughness={.9} /></mesh>
<mesh geometry={meshes[1]} receiveShadow castShadow><meshStandardMaterial color="#66705e" side={DoubleSide} roughness={.85} /></mesh>
</group>;
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useMemo } from "react";
import { BufferGeometry, Float32BufferAttribute, DoubleSide } from "three";
import navigation from "../../server/generated/arena-navigation.json";
export function ArenaNavigationSurface({ stageId }: { stageId: string }) {
const geometry = useMemo(() => {
const mesh = new BufferGeometry();
mesh.setAttribute("position", new Float32BufferAttribute((navigation as Record<string, number[]>)[stageId] ?? [], 3));
mesh.computeVertexNormals();
return mesh;
}, [stageId]);
useEffect(() => () => geometry.dispose(), [geometry]);
return <mesh geometry={geometry} position={[0, -.12, 0]} receiveShadow>
<meshStandardMaterial color="#465142" side={DoubleSide} roughness={.92} />
</mesh>;
}
+59
View File
@@ -0,0 +1,59 @@
import { useMemo, useRef, type RefObject } from "react";
import { useFrame } from "@react-three/fiber";
import { Group, Raycaster, Vector3 } from "three";
import { arenaCameraClearDistance, arenaCameraPose, arenaSpawnYaw, ARENA_CAMERA_DISTANCE } from "../game/arenaCamera";
import { cameraRelativeMovement, cameraYawAfterHorizontalLook, clampCameraPitch, DEFAULT_CAMERA_PITCH } from "../game/inputMath";
import { consumeMouseLook, consumeJumpRequest, isMouseLookDragActive, readInputSnapshot } from "../game/inputManager";
import { useArenaStore } from "../game/arenaStore";
import type { ArenaActorSnapshot, ArenaStageManifest } from "../game/arenaTypes";
export function ArenaPlayerCamera({ actor, stage, player, environment, movement, blocked }: {
actor: ArenaActorSnapshot | null;
stage: ArenaStageManifest;
player: RefObject<Group | null>;
environment: RefObject<Group | null>;
movement: RefObject<[number, number]>;
blocked: boolean;
}) {
const orbit = useRef({ yaw: arenaSpawnYaw(stage, actor?.team ?? 0), pitch: DEFAULT_CAMERA_PITCH });
const target = useMemo(() => new Vector3(), []);
const desired = useMemo(() => new Vector3(), []);
const ray = useMemo(() => new Raycaster(), []);
const distance = useRef(ARENA_CAMERA_DISTANCE);
const sent = useRef({ x: NaN, z: NaN, time: 0 });
useFrame(({ camera, clock }, rawDelta) => {
if (!actor) return;
const delta = Math.min(0.05, rawDelta);
const input = readInputSnapshot();
const [mouseX, mouseY] = consumeMouseLook();
if (consumeJumpRequest() !== null && !blocked && actor.alive) useArenaStore.getState().intent({ type: "jump" });
if (!blocked) {
const dragging = isMouseLookDragActive();
orbit.current.yaw = cameraYawAfterHorizontalLook(orbit.current.yaw, input.lookX * delta * 2.25 + (dragging ? mouseX * 0.0021 : 0));
orbit.current.pitch = clampCameraPitch(orbit.current.pitch + input.lookY * delta * 1.55 + (dragging ? mouseY * 0.0017 : 0));
}
const [x, z] = blocked || !actor.alive ? [0, 0] : cameraRelativeMovement(input.moveX, input.moveForward, orbit.current.yaw);
movement.current = [x, z];
// Publish immediately on input changes, and refresh held input through preparation/reconnect.
if (Math.abs(x - sent.current.x) > 0.002 || Math.abs(z - sent.current.z) > 0.002 || clock.elapsedTime - sent.current.time > 0.1 || !Number.isFinite(sent.current.x)) {
useArenaStore.getState().intent({ type: "movement", movement: { x, z } });
if (isMouseLookDragActive() || Math.abs(input.lookX) > .01) useArenaStore.getState().intent({ type: "facing", facing: orbit.current.yaw });
sent.current = { x, z, time: clock.elapsedTime };
}
const foot = player.current?.position.toArray() ?? actor.position;
const pose = arenaCameraPose(foot as [number, number, number], orbit.current.yaw, orbit.current.pitch);
target.set(...pose.target);
desired.set(...pose.position);
const clear = environment.current ? arenaCameraClearDistance(target, desired, environment.current, ray) : ARENA_CAMERA_DISTANCE;
distance.current = clear < distance.current ? clear : Math.min(clear, distance.current + delta * 8);
// A tight starting room can require a first-person distance. Keep the
// character's own head/shoulders out of that view, then restore the body
// automatically as the camera backs out into the arena.
if (player.current) player.current.visible = actor.alive && distance.current > 0.9;
desired.sub(target).normalize().multiplyScalar(distance.current).add(target);
// Never interpolate from a spectator/world-origin anchor through the arena walls.
camera.position.copy(desired);
camera.lookAt(target);
});
return null;
}
+8
View File
@@ -340,6 +340,14 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
quality={effectiveQuality}
active={active}
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
fallback={(
<CombatEffectPrimitive
event={effect.event}
quality={effectiveQuality}
active={active}
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
/>
)}
/>
) : (
<CombatEffectPrimitive
+12 -16
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject, type ReactNode } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import {
AdditiveBlending,
@@ -35,6 +35,7 @@ import {
originalCombatAnchorActorId,
originalAuraMatchesPresentationEvent,
originalEmitterParticleBudget,
selectCombatEffectRender,
parseOriginalCombatEffectGraph,
type OriginalCombatEffectAnimatedTrackName,
type OriginalCombatEffectGraph,
@@ -730,15 +731,12 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
quality,
active,
onDone,
}: OriginalCombatEffectProps) {
fallback,
}: OriginalCombatEffectProps & { readonly fallback: ReactNode }) {
const [loaded, setLoaded] = useState<readonly LoadedOriginalGraph[] | null>(null);
const onDoneRef = useRef(onDone);
const terminalRef = useRef(false);
onDoneRef.current = onDone;
useEffect(() => {
let cancelled = false;
terminalRef.current = false;
setLoaded(null);
void preloadCombatEffectManifest().then(async () => {
const assets = combatOriginalPhaseAssets(event);
@@ -756,21 +754,19 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
failures,
);
}
if (ready.length) {
setLoaded(ready);
return;
}
if (!terminalRef.current) {
terminalRef.current = true;
onDoneRef.current();
}
// Keep any usable original layers; only substitute when none can render.
setLoaded(ready);
}).catch(() => {
if (!cancelled) setLoaded([]);
});
return () => {
cancelled = true;
};
}, [event]);
return loaded ? (
if (loaded === null) return null;
const selection = selectCombatEffectRender("original", loaded.map((item) => item.asset));
return selection.kind === "original" ? (
<LoadedOriginalCombatEffect
event={event}
quality={quality}
@@ -778,7 +774,7 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
loaded={loaded}
onDone={onDone}
/>
) : null;
) : fallback;
}, (previous, next) => (
previous.event === next.event
&& previous.quality === next.quality
@@ -293,7 +293,7 @@ describe("original combat-effect graph contract", () => {
expect(originalEmitterParticleBudget(inactive, "high")).toBe(0);
});
it("never substitutes generated rendering when Original has no graph asset", () => {
it("prefers original rendering and falls back to generated when no original graph is available", () => {
expect(selectCombatEffectRender("generated", undefined)).toEqual({ kind: "generated" });
expect(selectCombatEffectRender("original", [
{},
@@ -302,7 +302,8 @@ describe("original combat-effect graph contract", () => {
kind: "original",
effectGraphUrl: "/assets/game/combat-effects/original/wow335/priest/graphs/smite.json",
});
expect(selectCombatEffectRender("original", undefined)).toBeNull();
expect(selectCombatEffectRender("original", [{ effectGraphUrl: " " }])).toBeNull();
expect(selectCombatEffectRender("original", undefined)).toEqual({ kind: "generated" });
expect(selectCombatEffectRender("original", [])).toEqual({ kind: "generated" });
expect(selectCombatEffectRender("original", [{ effectGraphUrl: " " }])).toEqual({ kind: "generated" });
});
});
+2 -2
View File
@@ -682,12 +682,12 @@ export type OriginalCombatEffectRenderSelection =
export function selectCombatEffectRender(
mode: CombatVfxMode,
phaseAssets: readonly OriginalCombatEffectPhaseAsset[] | null | undefined,
): OriginalCombatEffectRenderSelection | null {
): OriginalCombatEffectRenderSelection {
if (mode === "generated") return { kind: "generated" };
const authentic = phaseAssets?.find((asset) => (
typeof asset.effectGraphUrl === "string" && asset.effectGraphUrl.trim().length > 0
));
return authentic?.effectGraphUrl
? { kind: "original", effectGraphUrl: authentic.effectGraphUrl }
: null;
: { kind: "generated" };
}
+64
View File
@@ -3711,3 +3711,67 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
color: #bdb6a5;
font-size: 0.68rem;
}
.arena-player-hud {
position: absolute;
z-index: 4;
left: 50%;
bottom: max(.6rem, env(safe-area-inset-bottom));
transform: translateX(-50%);
width: min(56rem, calc(100% - 1.2rem));
display: grid;
justify-items: center;
gap: .3rem;
pointer-events: none;
}
.arena-player-hud > .action-bar { position: relative; inset: auto; transform: none; width: min(100%, 38rem); pointer-events: auto; }
.arena-player-status, .arena-cast-status, .arena-action-feedback {
padding: .25rem .6rem;
border: 1px solid #88966b77;
border-radius: .25rem;
background: #07130fe8;
text-align: center;
font-size: .75rem;
}
.arena-player-status { display: flex; flex-wrap: wrap; justify-content: center; gap: .5rem; }
.arena-player-status span { color: #d1dbc8; }
.arena-action-feedback { color: #ffe4a1; }
.arena-control-hints { font-size: .65rem; color: #e0e7d5; background: #07130fd9; padding: .2rem .4rem; text-align: center; }
.arena-personal-bars { display: grid; gap: .25rem; max-width: 100%; pointer-events: auto; }
.arena-personal-bars section { display: grid; gap: .2rem; max-width: 100%; }
.arena-personal-bars button {
position: relative; width: clamp(2.6rem, 5vw, 4.1rem); min-width: 0; min-height: 3.9rem;
display: grid; justify-items: center; gap: .1rem; padding: .2rem;
border: 1px solid #9dad8277; background: #08130feb; color: #fff;
}
.arena-personal-bars button:disabled { opacity: .55; }
.arena-personal-bars button kbd { position: absolute; top: .1rem; right: .2rem; z-index: 1; color: #fff5aa; font-size: .7rem; text-shadow: 1px 1px #000; }
.arena-personal-bars button .action-slot__icon { width: 1.9rem; height: 1.9rem; }
.arena-personal-bars button > span:last-of-type { font-size: .58rem; line-height: 1.15; overflow: hidden; }
.arena-personal-bars button em { position: absolute; top: 1rem; font-size: .9rem; background: #000b; }
.arena-runtime .arena-exit { z-index: 5; bottom: auto; top: .7rem; }
.arena-result-panel { z-index: 8; }
.arena-countdown { top: 28%; pointer-events: none; }
@media (max-width: 760px) {
.arena-player-hud { width: calc(100% - .5rem); }
.arena-player-hud .action-slot strong { font-size: .55rem; }
.arena-player-hud .action-bar--compact { padding: .25rem; }
.arena-team-frames { width: min(10rem, 33vw); font-size: .72rem; }
.arena-team-frames button small { font-size: .55rem; }
.arena-control-hints { font-size: .55rem; }
.arena-countdown { top: 38%; min-width: 8rem; padding: .4rem; }
}
.arena-toolbar { position: absolute; z-index: 6; right: .8rem; bottom: .6rem; display: flex; flex-wrap: wrap; gap: .3rem; max-width: 26rem; justify-content: flex-end; }
.arena-toolbar button, .arena-pet-orders button { padding: .35rem .6rem; background: #17241fe8; border: 1px solid #69837a; border-radius: .3rem; color: #dcece4; text-transform: capitalize; }
.arena-reference-panel svg { display: block; width: 100%; height: 250px; background: #11201b; border: 1px solid #728376; }
.arena-reference-panel { max-height: 85vh; overflow: auto; }
.arena-ground-hint { position: absolute; top: 22%; left: 50%; transform: translateX(-50%); z-index: 5; padding: .6rem 1rem; border-radius: .4rem; color: #c5ffdd; background: #13291dec; pointer-events: none; }
.arena-summon-name { color: #e1eee4; font-size: 10px; white-space: nowrap; text-shadow: 0 1px 3px #000; pointer-events: none; }
.arena-extra-resources, .arena-aura-strip, .arena-pet-orders { display: flex; flex-wrap: wrap; justify-content: center; gap: .4rem; font-size: .7rem; }
.arena-extra-resources span, .arena-aura-strip span { background: #12201dda; border: 1px solid #4a6860; border-radius: .25rem; padding: .2rem .4rem; }
.arena-aura-strip img { width: 20px; height: 20px; vertical-align: middle; margin-right: .25rem; }
.arena-aura-strip .is-debuff { border-color: #c65a58; }
.arena-player-hud { bottom: 3rem; }
.arena-combat-log { position: absolute; left: .7rem; top: 47%; color: #e4e3cc; font-size: .75rem; pointer-events: none; max-width: 18rem; }
.arena-team-frames button.is-selected { outline: 2px solid #ffda8b; }
@media (max-width: 760px) { .arena-toolbar { max-width: 100%; left: .5rem; justify-content: center; } .arena-toolbar button { font-size: .6rem; padding: .25rem; } }
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more