updating buff and debuff icons. separated COA classes from WoW classes on the character creation screen

This commit is contained in:
phenom
2026-08-16 11:16:51 -04:00
parent b5c9bce1aa
commit c00f3211ea
39 changed files with 2037 additions and 208 deletions
+83 -23
View File
@@ -5,6 +5,9 @@ Healer Man uses the same local-Gitea deployment pattern as the previous
filesystem, mount the runnable checkout into one Node container, and update it
with a local fast-forward pull plus an app restart.
For the repeatable Git, TrueNAS, signed-APK, and Obtainium release checklist,
follow [RELEASE_RUNBOOK.md](RELEASE_RUNBOOK.md).
The public URL remains `https://iwanttoheal.phenomrom.com`. The old game and
Healer Man cannot both own host port `4173`; stop the old app before the final
cutover. Keep its checkout and database until the replacement is verified.
@@ -14,7 +17,7 @@ cutover. Keep its checkout and database until the replacement is verified.
The `healer-man` TrueNAS app is the browser host and online account server. One
Node 24 process serves the production Vite bundle and authenticated `/api`
routes on port `4173`. Its SQLite database stores accounts, hashed credentials,
sessions, and cloud rosters under `/app/data/game.db`.
sessions, and cloud rosters under `/app/runtime-data/game.db`.
The source checkout and persistent data are separate mounts. Replacing the
container, rebuilding `dist`, or pulling source must not replace the database.
@@ -63,18 +66,16 @@ clone alone cannot retrieve them.
## First installation
Create an empty Gitea repository named `phenom/healer-man`, then initialize and
push the local `D:\Projects\HealerMan` project to its `main` branch.
push the local `F:\Projects\HealerMan` project to its `main` branch.
The current project directory is not yet a Git working tree. After creating the
empty Gitea repository, run this once from PowerShell on the development PC:
The project is already connected to Gitea. Verify the checkout and both remote
URLs from PowerShell with these exact commands:
```powershell
Set-Location D:\Projects\HealerMan
git init -b main
git remote add origin https://git.whoagland.com/phenom/healer-man.git
git add .
git commit -m "Initial Healer Man import"
git push -u origin main
Set-Location F:\Projects\HealerMan
git status --short --branch
git remote -v
git branch --show-current
```
If the repository slug differs, change both the remote URL here and every
@@ -129,7 +130,7 @@ services:
environment:
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
CONTENT_DIR: /app/content
DATA_DIR: /app/data
DATA_DIR: /app/runtime-data
HOST: 0.0.0.0
NODE_OPTIONS: "--max-old-space-size=4096"
PORT: "4173"
@@ -143,13 +144,15 @@ services:
restart: unless-stopped
volumes:
- /mnt/usbssds/apps/healer-man/app:/app
- /mnt/usbssds/apps/healer-man/data:/app/data
- /mnt/usbssds/apps/healer-man/data:/app/runtime-data
- /mnt/usbssds/apps/healer-man/content:/app/content
working_dir: /app
```
Do not remove either persistent mount. `/app/data` owns account/cloud-save data;
`/app/content` owns downloadable manifests and immutable content objects. The
Do not remove either persistent mount. `/app/runtime-data` owns
account/cloud-save data; `/app/content` owns downloadable manifests and
immutable content objects. Never mount a volume at `/app/data`, because that
would hide the checkout's required `/app/data/loot/schema.sql`. The
startup command installs locked dependencies, initializes the schema, publishes
new or changed content objects, builds the full browser bundle, and starts the
combined static/API/content server. Existing content objects are reused.
@@ -173,15 +176,64 @@ schema-compatible with Healer Man and must not be copied over the new one.
## Update workflow
Push `main` from the development PC. Then run on TrueNAS:
For a code update that includes a new APK, choose the next version first. The
example below upgrades `0.1.0` to `0.1.1`; replace `2` if it is not the next
unused Android `versionCode`. Then run these commands in **Windows PowerShell**
on the development PC. Stop if any test or build fails:
```sh
git -C /mnt/usbssds/apps/healer-man/app pull --ff-only \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git \
main
```powershell
Set-Location F:\Projects\HealerMan
$VersionName = '0.1.1'
$VersionCode = 2
npm version $VersionName --no-git-tag-version
npm run content:test
npm run server:test
npm test
npm run build
npm test -- --run src/avatar
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
git status --short
git diff --check
git add -A
git status --short
git diff --cached --stat
git diff --cached --check
git commit -m "Release Healer Man $VersionName"
git push origin main
git rev-parse HEAD
```
Restart `healer-man` in the TrueNAS Apps UI afterward. Container startup
Before `git commit`, read the staged file list printed by `git status --short`.
Do not commit a `.env` file, password, API token, keystore, APK, database,
`content/`, `dist/`, `dist-android/`, or `.android-public/` output. The final
command prints the exact deployed commit ID.
Next, open the TrueNAS Apps UI and **stop** `healer-man`. Open a TrueNAS shell
and run these exact commands:
```sh
sudo mkdir -p /mnt/usbssds/apps/healer-man/backups
if sudo test -f /mnt/usbssds/apps/healer-man/data/game.db; then
sudo cp -p \
/mnt/usbssds/apps/healer-man/data/game.db \
/mnt/usbssds/apps/healer-man/backups/game-$(date +%Y%m%d-%H%M%S).db
fi
sudo git config --global --add safe.directory \
/mnt/usbssds/apps/healer-man/app
sudo git -C /mnt/usbssds/apps/healer-man/app pull --ff-only \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git \
main
sudo chown -R truenas_admin:truenas_admin \
/mnt/usbssds/apps/healer-man/app
sudo git -C /mnt/usbssds/apps/healer-man/app rev-parse HEAD
```
The TrueNAS commit ID must match the one printed on the development PC. Start
`healer-man` in the TrueNAS Apps UI afterward. Container startup
publishes immutable content objects and replaces `manifest.json` only after all
objects exist, then rebuilds the browser client and starts the server. Android
devices discover the small new manifest and download only missing or changed
@@ -189,6 +241,14 @@ files. Asset-only updates do not require a new APK. A failed fast-forward pull
deliberately leaves the running checkout unchanged; resolve diverged history on
the development machine rather than forcing the TrueNAS checkout.
After the logs say the server is listening on port `4173`, run on TrueNAS:
```sh
curl -fI http://127.0.0.1:4173/
curl -fsS http://127.0.0.1:4173/api/health
curl -fsS http://127.0.0.1:4173/content/manifest.json | head -c 500
```
To publish content manually without restarting the app container, run inside a
Node 24 environment that mounts the checkout and content volume:
@@ -222,11 +282,11 @@ Install JDK 21 and Android SDK Platform 36 on the development PC and set
`JAVA_HOME` plus `ANDROID_SDK_ROOT`. The build wrapper also recognizes the
project's existing portable JDK/SDK layout when present.
For each Android version, push `main` first. Then set the permanent signing-key
secrets and Gitea token in the development shell:
For each Android version, complete the exact update workflow above first. Then
set the permanent signing-key secrets and Gitea token in PowerShell:
```powershell
$env:ANDROID_KEYSTORE_FILE = 'D:\secure\healer-man-release.jks'
$env:ANDROID_KEYSTORE_FILE = 'F:\secure\healer-man-release.jks'
$env:ANDROID_KEYSTORE_PASSWORD = '...'
$env:ANDROID_KEY_ALIAS = 'healer-man'
$env:ANDROID_KEY_PASSWORD = '...'
+87
View File
@@ -4,6 +4,71 @@ This file is the durable record of runtime behavior, regressions, and the
evidence required before calling a visual bug fixed. Update it whenever scene,
camera, input, physics, mob-rendering, or asset-loading code changes.
## 2026-08-16 responsive jump and natural teammate movement - COMPLETE
- Added the grounded single-jump path for Space and controller L3, including a
`7.2 m/s` launch, `100 ms` coyote time, `120 ms` input buffering, landing
lockout, upward-normal ground filtering, and blocked-input cancellation.
Player physics and the latest grounded navigation anchor are now reported
separately, so party breadcrumbs and recalls do not follow an airborne
player position.
- Player locomotion now presents rising, falling, and landing phases. The live
WoW human used its native jump pose while preserving the visible body and
equipped weapon; RuneWaker and fallback clip selection is covered by the
focused animation suite.
- Party members now derive stable movement personalities from member ID and
living-party rank. The runtime applies personalized trail distance, lateral
offset, comfort slack, speed, slow sway, stop/start hysteresis, combat
bearing, preferred range, and ally separation only when the candidate path
passes collision and floor-continuity checks. Breadcrumb centerline routing,
Stop, stuck recovery, role decisions, and blocked-route reporting remain the
fallback behavior.
- Focused verification passed 17 files / 117 tests:
`playerJump`, `inputManager`, `inputMath`, `store`, `combatAnimation`,
`partyMovement`, `partyNavigation`, `partyPathing`, `partyRuntime`, and the
complete `src/avatar` suite. The first full-suite attempts exposed a
pre-existing load-sensitive `30 s` timeout in the heavyweight RuneWaker
animation audit; the isolated test passed in `6.5 s`. Its two asset-audit
cases now use `60 s`, matching their actual full-suite workload more closely.
- Final `npm test`: **114 files / 656 tests passed**. Final `npm run build`:
**passed**, including TypeScript, 954 transformed Vite modules, and the KTX2
audit of 11,839 compressed textures with zero fallbacks.
- Production preview was exercised at `http://127.0.0.1:4174/`. At desktop
`1440 x 900`, Space produced a visible jump from rest and while moving; the
running capture shows the native raised-arm jump pose. Space pressed while
paused did not launch on resume. Five rapid Space presses during one airborne
cycle returned to a normal grounded pose rather than producing a double jump.
The entrance smoke route covered its sloped floor and low cavern roof without
visible clipping or a hidden avatar.
- In Defend, the companions settled at visibly different longitudinal and
lateral offsets. In Attack, tank, ranged, and melee actors held distinct
bearings; Brynn peeled to the right while the other roles retained separate
safe positions. Party health and combat activity continued updating. Stop and
Recall were then issued from the Thor companion display and the authoritative
command state updated correctly.
- The browser Thor preview displayed both the `960 x 540` main surface and
`620 x 540` companion surface. Entering as healer from the companion display,
Attack/Stop/Recall dispatch, Space jumping on the main display, and
right-mouse input with no pointer lock all worked. Runtime inspection found
one visible canvas, no loading screen, and no scene-error overlay.
- Console capture contains no errors. It contains two occurrences of the
project's existing third-party initialization deprecation warning, one per
desktop/Thor runtime load. The Vite preview host separately logged the known
optional `/content/manifest.json` proxy refusal because the auxiliary content
server on port 4173 was not running; the packaged dungeon still loaded and
played normally.
- Evidence is under `playtest-artifacts/2026-08-16-jump-party/`:
`desktop-running-jump-preview.png`, `desktop-varied-follow-preview.png`,
`desktop-varied-combat-preview.png`, `desktop-pause-jump-blocked-preview.png`,
`thor-jump-preview.png`, `thor-varied-combat-preview.png`,
`thor-repeated-jump-landing-preview.png`, and `preview-console.txt`.
- Coverage limits: no physical controller was connected, so L3 edge behavior
and removal of L3 clear-target are automated-test verified rather than
hardware verified. The entrance smoke route did not provide a deterministic
walk-off ledge or narrow doorway fixture; exact coyote timing and lateral
centerline fallback remain covered by their pure/navigation tests rather than
claimed as live edge-case evidence.
## 2026-08-01 all-dungeon v5 animation acceptance pass - COMPLETE
- The Blackhorn Silencer and Giant Assassin failures demonstrated that valid
@@ -1182,3 +1247,25 @@ Vite's existing large-chunk advisory remains informational.
- Evidence reviewed during the browser run: login, roster-preview, Downloads,
and in-dungeon screenshots. Browser console contained no errors; one existing
third-party initialization deprecation warning was present.
# 2026-08-16 — CoA character-creation category
- Opened the offline roster and entered character creation in the live Vite
client. The header rendered distinct **WoW**, **RoM**, and **CoA** tabs without
clipping.
- The WoW tab showed only the eight standard WoW classes available to Human;
no custom CoA class appeared.
- The CoA tab reused the Azeroth race roster and showed the 16 custom CoA
classes available to Human, defaulting to Barbarian. The character body and
starter weapon silhouette remained visible while switching categories.
- In the production preview, the saved Human Priest rendered in the roster with
her full body and starter staff silhouette, then entered Wailing Caverns with
her body, staff, party, dungeon environment, and HUD visible.
- Browser diagnostics contained no errors.
- Evidence: `playtest-artifacts/character-create-wow-category-2026-08-16.png`
`playtest-artifacts/character-create-coa-category-2026-08-16.png`,
`playtest-artifacts/character-category-roster-2026-08-16.png`, and
`playtest-artifacts/character-category-dungeon-2026-08-16.png`.
- Verification: 71 focused catalog/store/repository/dungeon/ability/party tests
passed, all 28 avatar tests passed, and `npm run build` completed
successfully.
+2 -1
View File
@@ -129,12 +129,13 @@ an accidentally reintroduced PNG/JPEG world texture fails before deployment.
## Controls
- Move: WASD, arrow keys, or left stick
- Jump: Space or L3
- Look: hold the right mouse button for mouse look, or use right stick
- Base abilities: keyboard 18; controller Cross, Square, Triangle, Circle,
R1, R2, D-pad Left, and D-pad Right
- Secondary abilities: hold Shift or L1 while pressing an ability control
- Tertiary abilities: hold Alt or L2 while pressing an ability control
- Hostile target: Tab or R3; clear targets with L3
- Hostile target: Tab or R3
- Friendly target: click a party/player frame or use D-pad Up/Down to cycle
living allies and yourself
- Party orders: F1 Attack, F2 Defend, F3 Stop
+606
View File
@@ -0,0 +1,606 @@
# Healer Man update and release runbook
Use this checklist whenever game changes need to be pushed to Gitea, deployed
to the TrueNAS server, and released as an Obtainium-compatible APK.
The examples below use Android version `0.1.1` and `versionCode` `2`. Replace
both with the next values for the release you are making. Every APK must use a
larger `versionCode` than every APK previously released.
## What requires a new APK?
Use the full procedure in this document when the update changes any of these:
- React, TypeScript, JavaScript, gameplay, UI, shaders, or asset-loading code.
- Capacitor or native Android code, permissions, or configuration.
- The downloadable-content manifest format or compatibility rules.
- Starter content that must work immediately after a fresh offline install.
For an asset-only update, such as a compatible GLB, texture, audio file, or data
file, use the shorter procedure at the end. Those updates can be published by
the server and downloaded by an already-installed APK without an Obtainium
release.
Single-player remains offline-capable. A fresh APK includes the runtime and
starter content. Additional content that a player has downloaded is retained
in Android app-private storage and can be used offline after download.
## Fixed locations
```text
Development checkout:
F:\Projects\HealerMan
Gitea repository:
https://git.whoagland.com/phenom/healer-man
Git push endpoint:
ssh://git@192.168.1.180:30009/phenom/healer-man.git
TrueNAS working checkout:
/mnt/usbssds/apps/healer-man/app
TrueNAS bare Gitea repository:
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git
Persistent database:
/mnt/usbssds/apps/healer-man/data/game.db
Published downloadable content:
/mnt/usbssds/apps/healer-man/content
Public game URL:
https://iwanttoheal.phenomrom.com
```
The commands in the Windows sections are PowerShell commands. The commands in
the TrueNAS sections are shell commands entered after signing in to TrueNAS.
## One-time release setup
These items only need to be configured once on the development PC.
### 1. Confirm Git uses LAN SSH for pushes
```powershell
Set-Location F:\Projects\HealerMan
git remote -v
ssh -T -p 30009 git@192.168.1.180
```
The SSH test should say that Gitea authenticated you as `phenom`. The current
repository is configured to fetch over HTTPS and push over LAN SSH.
If the push URL ever needs to be restored, run:
```powershell
git remote set-url --push origin `
ssh://git@192.168.1.180:30009/phenom/healer-man.git
```
### 2. Install the Android build requirements
Install JDK 21 and Android SDK Platform 36. Configure their actual paths, then
verify them:
```powershell
$env:JAVA_HOME = 'C:\path\to\jdk-21'
$env:ANDROID_SDK_ROOT = "$env:LOCALAPPDATA\Android\Sdk"
& "$env:JAVA_HOME\bin\java.exe" -version
Test-Path "$env:ANDROID_SDK_ROOT\platforms\android-36\android.jar"
```
The last command must return `True`. The build wrapper can also use the portable
JDK and Android SDK locations described in `scripts/runAndroidGradle.ps1`.
### 3. Create and protect one permanent signing key
Skip this step if the permanent Healer Man release key already exists.
```powershell
New-Item -ItemType Directory -Force F:\secure
keytool -genkeypair -v `
-keystore F:\secure\healer-man-release.jks `
-alias healer-man `
-keyalg RSA `
-keysize 4096 `
-validity 10000
```
Back up the keystore and its passwords somewhere secure. Do not put them in the
repository. Android will not install a future update over the existing game if
it is signed with a different key.
### 4. Create a Gitea access token
In Gitea, create an access token for `phenom` that can create repository
releases and upload attachments to `phenom/healer-man`. Store it in a password
manager. Never add the token to a file in the repository or distribute it to
players.
Obtainium must be able to read the release without your Gitea credentials.
Either make `phenom/healer-man` publicly readable or publish the APK to a
separate public release-only repository. Gitea release visibility follows the
repository's access rules.
### 5. Confirm the TrueNAS app has the correct data mount
The persistent database must be mounted at `/app/runtime-data`. Do **not** mount
anything at `/app/data`, because `/app/data/loot/schema.sql` is source data that
the build needs. The complete known-good YAML is in the appendix.
## Full release: Git, server, and APK
### Step 1: Open the project and inspect every change
```powershell
Set-Location F:\Projects\HealerMan
git status --short --branch
git diff --stat
git diff
```
Check that every listed file belongs to this update. Do not use `git reset
--hard` or discard unfamiliar changes. Before continuing, make sure the project
contains no passwords, Gitea tokens, `.env` files, keystores, database files, or
other secrets.
### Step 2: Select the Android version
Choose both values before building:
```powershell
$VersionName = '0.1.1'
$VersionCode = 2
```
- `VersionName` is the player-facing semantic version.
- `VersionCode` is Android's integer update counter. It must always increase.
- Record the last used `versionCode` in the Gitea release notes or your release
records so it is never accidentally reused.
Update `package.json` and `package-lock.json` to the same version:
```powershell
npm version $VersionName --no-git-tag-version
```
Do not create a local Git tag with `npm version`; the release script creates the
Gitea tag after the signed APK builds successfully.
### Step 3: Run automated validation
```powershell
npm run content:test
npm run server:test
npm test
npm run build
```
All four commands must succeed.
Apply these additional targeted checks whenever the matching area was edited:
```powershell
# Character, gear, appearance, weapon, or attachment changes:
npm test -- --run src/avatar
# Camera or desktop input changes:
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
```
Then run the game locally and verify:
```powershell
npm run dev
```
- Check the roster preview and an in-dungeon character. Confirm that bodies,
weapons, and equipped silhouettes render, and record screenshot evidence in
`PLAYTEST_NOTES.md`.
- Hold and drag the right mouse button in the 3D scene. Confirm the camera moves,
releasing the button stops it, no browser capture prompt appears, and
`document.pointerLockElement` remains `null`.
- Exercise the gameplay, UI, and content changed by this release.
- Stop the development server with `Ctrl+C` when finished.
### Step 4: Review and commit the release
First check for whitespace errors and review the final file list:
```powershell
git diff --check
git status --short
git diff --stat
```
Stage the intended release:
```powershell
git add -A
git status --short
git diff --cached --stat
git diff --cached --check
```
Do not continue if the staged list contains a keystore, APK, `.env` file,
database, `content/`, `dist/`, `dist-android/`, or `.android-public/` output.
Unstage any accidental file with `git restore --staged -- <path>` without
deleting the local file.
Commit only after the staged set is correct:
```powershell
git commit -m "Release Healer Man $VersionName"
$ReleaseCommit = git rev-parse HEAD
$ReleaseCommit
```
Keep the printed commit ID so it can be compared with TrueNAS.
### Step 5: Push `main` to Gitea
```powershell
git push origin main
git status --short --branch
git ls-remote origin refs/heads/main
```
The push must finish successfully and the remote `main` hash must equal
`$ReleaseCommit`. Open the repository in Gitea and confirm the new commit is at
the top of `main`.
If SSH authentication fails, test it again:
```powershell
ssh -T -p 30009 git@192.168.1.180
```
Do not switch this large repository back to an HTTPS push just to work around an
error; its large initial pack previously exceeded the HTTPS proxy connection.
### Step 6: Stop and back up the TrueNAS app
In the TrueNAS Apps UI, stop the `healer-man` app. This prevents the running
container from reading files while its checkout changes.
Then sign in to a TrueNAS shell and create a point-in-time SQLite copy:
```sh
sudo mkdir -p /mnt/usbssds/apps/healer-man/backups
if sudo test -f /mnt/usbssds/apps/healer-man/data/game.db; then
sudo cp -p \
/mnt/usbssds/apps/healer-man/data/game.db \
/mnt/usbssds/apps/healer-man/backups/game-$(date +%Y%m%d-%H%M%S).db
fi
df -h /mnt/usbssds/apps/healer-man
```
A TrueNAS dataset snapshot is recommended in addition to the file copy,
especially before a database migration.
### Step 7: Pull the release into the TrueNAS working checkout
The safe-directory commands are normally one-time setup, but running them again
is harmless:
```sh
sudo git config --global --add safe.directory \
/mnt/usbssds/apps/healer-man/app
sudo git config --global --add safe.directory \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git
```
Pull from the local Gitea bare repository:
```sh
sudo git -C /mnt/usbssds/apps/healer-man/app pull --ff-only \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git \
main
sudo chown -R truenas_admin:truenas_admin \
/mnt/usbssds/apps/healer-man/app
sudo git -C /mnt/usbssds/apps/healer-man/app rev-parse HEAD
```
The final commit ID must match `$ReleaseCommit` from the development PC. Always
use `sudo` for this pull: `truenas_admin` cannot read the bare Gitea repository
directly.
### Step 8: Start the TrueNAS app and watch startup
Start `healer-man` in the TrueNAS Apps UI, then open its logs. A successful
startup performs these actions in order:
1. Installs the locked dependencies.
2. Initializes `/app/runtime-data/game.db`.
3. Publishes the content manifest and immutable objects to `/app/content`.
4. Generates the loot catalog and builds the browser client.
5. Starts the combined web/API/content server on port `4173`.
The deprecation, funding, and `npm audit` lines are warnings; they do not by
themselves close the server. Continue until the logs show the server listening.
If the logs report this path, the YAML is still wrong:
```text
ENOENT: no such file or directory, open '/app/data/loot/schema.sql'
```
Change the database volume and `DATA_DIR` to `/app/runtime-data` using the YAML
in the appendix, then redeploy the app.
### Step 9: Verify the live server
From the TrueNAS shell:
```sh
curl -fI http://127.0.0.1:4173/
curl -fsS http://127.0.0.1:4173/api/health
curl -fsS http://127.0.0.1:4173/content/manifest.json | head -c 500
```
From the development PC or another LAN machine:
```powershell
curl.exe -fI https://iwanttoheal.phenomrom.com/
curl.exe -fsS https://iwanttoheal.phenomrom.com/api/health
```
Also open the public URL in a browser. Confirm the game loads, login works, and
the changed gameplay is present. If this release includes new or changed assets,
confirm the client sees the new content manifest and can retrieve one of those
assets.
### Step 10: Set release secrets for this PowerShell session
Set the permanent keystore path and alias:
```powershell
Set-Location F:\Projects\HealerMan
$env:ANDROID_KEYSTORE_FILE = 'F:\secure\healer-man-release.jks'
$env:ANDROID_KEY_ALIAS = 'healer-man'
```
Read the passwords and token without displaying them or placing them in command
history:
```powershell
$env:ANDROID_KEYSTORE_PASSWORD = [System.Net.NetworkCredential]::new(
'',
(Read-Host 'Keystore password' -AsSecureString)
).Password
$env:ANDROID_KEY_PASSWORD = [System.Net.NetworkCredential]::new(
'',
(Read-Host 'Key password' -AsSecureString)
).Password
$env:GITEA_TOKEN = [System.Net.NetworkCredential]::new(
'',
(Read-Host 'Gitea token' -AsSecureString)
).Password
```
If Java and the Android SDK are not configured permanently, set `JAVA_HOME` and
`ANDROID_SDK_ROOT` in this same shell now.
### Step 11: Build, sign, and publish the APK
Confirm the version variables still contain the intended values:
```powershell
$VersionName
$VersionCode
(Get-Content package.json -Raw | ConvertFrom-Json).version
```
The package version must match `$VersionName`. Then run:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File scripts/publish-gitea-release.ps1 `
-VersionName $VersionName `
-VersionCode $VersionCode `
-ReleaseNotes "Healer Man $VersionName client update; Android versionCode $VersionCode."
```
The script will:
1. Build the compact Android web bundle and synchronize Capacitor.
2. Build a signed release APK.
3. Name it `healer-man-VERSION-release.apk`.
4. Create a SHA-256 checksum file.
5. Create the Gitea tag and non-draft release.
6. Upload the APK and checksum as release attachments.
Do not interrupt it while Gradle or the upload is running.
### Step 12: Verify the APK and Obtainium release
Check the local artifact:
```powershell
$Apk = "android\app\build\outputs\apk\release\healer-man-$VersionName-release.apk"
Get-Item $Apk | Select-Object FullName, Length, LastWriteTime
Get-FileHash $Apk -Algorithm SHA256
```
Open:
```text
https://git.whoagland.com/phenom/healer-man/releases
```
Confirm the release has both attachments:
```text
healer-man-VERSION-release.apk
healer-man-VERSION-release.apk.sha256
```
On a test Android device:
1. Refresh Healer Man in Obtainium.
2. Confirm it detects the new version.
3. Install the update over the existing app; do not uninstall first.
4. Launch once online and allow changed content to download.
5. Disconnect Wi-Fi/mobile data and confirm single-player still starts and
previously downloaded content remains playable.
If Obtainium needs manual source settings, use the releases page above, select
the HTML source override, and filter APK links with:
```text
healer-man-.*-release\.apk$
```
### Step 13: Clear secrets from the shell
```powershell
Remove-Item Env:ANDROID_KEYSTORE_PASSWORD -ErrorAction SilentlyContinue
Remove-Item Env:ANDROID_KEY_PASSWORD -ErrorAction SilentlyContinue
Remove-Item Env:GITEA_TOKEN -ErrorAction SilentlyContinue
```
The keystore path and alias are not passwords, but they may also be cleared if
desired.
## Short procedure for an asset-only update
Use this only when no APK-owned code, native behavior, starter content, or
content compatibility rules changed.
Do **not** bump the Android version. Run these exact PowerShell commands:
```powershell
Set-Location F:\Projects\HealerMan
npm run content:test
npm run build
git status --short
git diff --check
git add -A
git status --short
git diff --cached --stat
git diff --cached --check
git commit -m "Update Healer Man assets"
git push origin main
git rev-parse HEAD
```
Read the staged file list before committing and stop if a test, build, or Git
command fails. Next, stop `healer-man` in the TrueNAS Apps UI and run this exact
block in a TrueNAS shell:
```sh
sudo mkdir -p /mnt/usbssds/apps/healer-man/backups
if sudo test -f /mnt/usbssds/apps/healer-man/data/game.db; then
sudo cp -p \
/mnt/usbssds/apps/healer-man/data/game.db \
/mnt/usbssds/apps/healer-man/backups/game-$(date +%Y%m%d-%H%M%S).db
fi
sudo git -C /mnt/usbssds/apps/healer-man/app pull --ff-only \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/healer-man.git \
main
sudo chown -R truenas_admin:truenas_admin \
/mnt/usbssds/apps/healer-man/app
sudo git -C /mnt/usbssds/apps/healer-man/app rev-parse HEAD
```
Start `healer-man` in the TrueNAS Apps UI. Startup runs `content:publish`, writes
new immutable objects, and atomically replaces `manifest.json` after the objects
exist. When the logs show the server listening, run:
```sh
curl -fI http://127.0.0.1:4173/
curl -fsS http://127.0.0.1:4173/api/health
curl -fsS http://127.0.0.1:4173/content/manifest.json | head -c 500
```
Finally, launch a currently installed APK while online. Confirm it downloads the
changed content, then disconnect the device and test the content offline.
Changing or adding an asset changes the published manifest. Because content is
addressed by its hash, an unchanged asset is reused while a changed file becomes
a new immutable object. Old clients continue using their active verified
manifest until the new download is complete; a partially downloaded update is
not activated.
## Rollback
Do not rewrite or force-push shared `main` history. If a source/server release is
bad, revert it from the development PC:
```powershell
Set-Location F:\Projects\HealerMan
git revert <bad-commit-id>
git push origin main
```
Then stop the TrueNAS app, pull the new revert commit with the Step 7 command,
and start the app again. Restore a database backup only if the failure changed
or damaged persistent data.
An already-installed Android APK cannot be rolled back in place to a lower
`versionCode`. Publish a corrected APK with a new version name and a still-higher
`versionCode`.
## Common failures
- **TrueNAS says the bare repository is not a Git repository:** run the pull
with `sudo`. The normal `truenas_admin` account cannot read Gitea's repository
storage.
- **`publish-content.mjs` is missing:** the TrueNAS checkout is not at the pushed
commit. Compare `git rev-parse HEAD` on both systems and pull again.
- **`/app/data/loot/schema.sql` is missing:** a volume is masking source
`/app/data`. Apply the corrected YAML below.
- **Git HTTPS push fails after uploading a large pack:** keep the configured LAN
SSH push URL and test port `30009` authentication.
- **APK installs as a separate app or refuses to update:** verify the app ID is
still `com.phenomrom.healerman`, use the same signing key, and increase
`versionCode`.
- **Gitea APK upload returns an HTTP size error:** increase the Gitea/reverse
proxy release-upload limit or use Gitea's direct LAN web endpoint with the
script's `-GiteaBaseUrl` option. Do not assume `192.168.1.180:8080` is Gitea;
verify the actual Gitea web port first.
- **Release creation succeeds but attachment upload fails:** do not repeatedly
create the same tag. Remove the incomplete release/tag in Gitea or upload the
attachments to that release, then retry only after its state is understood.
## Appendix: known-good TrueNAS YAML
The TrueNAS app name is `healer-man`:
```yaml
services:
healerman:
image: node:24-bookworm-slim
command: >-
sh -lc "npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
environment:
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
CONTENT_DIR: /app/content
DATA_DIR: /app/runtime-data
HOST: 0.0.0.0
NODE_OPTIONS: "--max-old-space-size=4096"
PORT: "4173"
SESSION_TTL_DAYS: "30"
STATIC_DIR: /app/dist
TRUST_PROXY: "true"
VITE_API_BASE_URL: "https://iwanttoheal.phenomrom.com"
init: true
ports:
- "4173:4173"
restart: unless-stopped
volumes:
- /mnt/usbssds/apps/healer-man/app:/app
- /mnt/usbssds/apps/healer-man/data:/app/runtime-data
- /mnt/usbssds/apps/healer-man/content:/app/content
working_dir: /app
```
The critical distinction is:
```text
Source loot schema: /app/data/loot/schema.sql
Persistent database: /app/runtime-data/game.db
```
Never add a volume mounted at `/app/data`.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.0",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.0",
"version": "0.1.2",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+17
View File
@@ -124,6 +124,23 @@ describe("local profile repository", () => {
});
});
it("migrates custom-class profiles into the CoA category", () => {
const storage = new MemoryStorage();
const offline = createOfflineSession();
const character = createCharacter({
ownerId: offline.ownerId,
name: "Conqueror",
categoryId: "wow",
raceId: "human",
classId: "barbarian",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
expect(character.categoryId).toBe("coa");
expect(listCharacters(offline.ownerId, storage)[0]?.categoryId).toBe("coa");
});
it("persists controller ability bindings per character without changing progression", () => {
const storage = new MemoryStorage();
const offline = createOfflineSession();
+2 -5
View File
@@ -1,6 +1,6 @@
import type { CharacterProfile, PlayerSession } from "./types";
import {
contentCategoryForRace,
contentCategoryForClass,
isRomClassId,
type RomClassId,
} from "./characterCatalog";
@@ -112,7 +112,6 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
equipment?: unknown;
manastormProgress?: unknown;
settings?: unknown;
categoryId?: unknown;
secondaryClassId?: unknown;
actionLoadouts?: unknown;
};
@@ -132,9 +131,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
.filter(([, abilityId]) => abilityId === null || typeof abilityId === "string")
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
: {};
const categoryId = legacy.categoryId === "rom" || legacy.categoryId === "wow"
? legacy.categoryId
: contentCategoryForRace(value.raceId);
const categoryId = contentCategoryForClass(value.classId);
const secondaryClassId = categoryId === "rom"
&& isRomClassId(value.classId)
&& typeof legacy.secondaryClassId === "string"
+27 -12
View File
@@ -8,12 +8,14 @@ import {
classIconUrl,
classesForRace,
clampAppearance,
contentCategoryForClass,
normalizeCharacterName,
randomAppearance,
supportedGenders,
totalRaceClassCombinations,
validateCharacterName,
} from "./characterCatalog";
import { CONTENT_CATEGORIES } from "./contentCategories";
describe("wow335a character catalog", () => {
it("limits normal play to the live-verified race roster", () => {
@@ -34,9 +36,13 @@ describe("wow335a character catalog", () => {
it("matches the extracted Ascension playable catalog", () => {
const wowRaces = RACES.filter((race) => race.categoryId !== "rom");
const wowClasses = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom");
const azerothClasses = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom");
const wowClasses = CLASSES.filter((characterClass) => contentCategoryForClass(characterClass.id) === "wow");
const coaClasses = CLASSES.filter((characterClass) => contentCategoryForClass(characterClass.id) === "coa");
expect(wowRaces).toHaveLength(27);
expect(wowClasses).toHaveLength(31);
expect(azerothClasses).toHaveLength(31);
expect(wowClasses).toHaveLength(10);
expect(coaClasses).toHaveLength(21);
expect(COA_CLASS_IDS).toHaveLength(21);
expect(wowRaces.reduce((total, race) => total + classesForRace(race.id).length, 0)).toBe(740);
expect(totalRaceClassCombinations()).toBe(758);
@@ -48,7 +54,7 @@ describe("wow335a character catalog", () => {
"Forest Troll", "Taunka", "Northrend Skeleton", "Ice Troll", "Earthen",
"Human Cultist",
]);
expect(COA_CLASS_IDS.map((id) => wowClasses.find((entry) => entry.id === id)?.name)).toEqual([
expect(COA_CLASS_IDS.map((id) => coaClasses.find((entry) => entry.id === id)?.name)).toEqual([
"Barbarian", "Witch Doctor", "Felsworn", "Witch Hunter", "Stormbringer",
"Knight of Xoroth", "Guardian", "Templar", "Bloodmage", "Ranger",
"Chronomancer", "Necromancer", "Pyromancer", "Cultist", "Starcaller",
@@ -56,16 +62,25 @@ describe("wow335a character catalog", () => {
]);
});
it("separates default WoW, custom CoA, and RoM character creation categories", () => {
expect(CONTENT_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom", "coa"]);
expect(classesForRace("human", "wow").map((entry) => entry.id)).toContain("priest");
expect(classesForRace("human", "wow").map((entry) => entry.id)).not.toContain("barbarian");
expect(classesForRace("human", "coa").map((entry) => entry.id)).toContain("barbarian");
expect(classesForRace("human", "coa").map((entry) => entry.id)).not.toContain("priest");
expect(classesForRace("rom-human", "rom").map((entry) => entry.id)).toContain("rom-priest");
});
it("applies the race eligibility rows from CharBaseInfo.dbc", () => {
const wowClassCount = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom").length;
expect(classesForRace("human").map((entry) => entry.id)).toContain("barbarian");
expect(classesForRace("human").map((entry) => entry.id)).not.toContain("felsworn");
expect(classesForRace("draenei").map((entry) => entry.id)).toContain("felsworn");
expect(classesForRace("draenei").map((entry) => entry.id)).toContain("knight-of-xoroth");
expect(classesForRace("troll").map((entry) => entry.id)).toContain("venomancer");
expect(classesForRace("troll").map((entry) => entry.id)).not.toContain("knight-of-xoroth");
expect(classesForRace("goblin")).toHaveLength(wowClassCount);
expect(classesForRace("human-cultist")).toHaveLength(wowClassCount);
expect(classesForRace("human", "coa").map((entry) => entry.id)).toContain("barbarian");
expect(classesForRace("human", "coa").map((entry) => entry.id)).not.toContain("felsworn");
expect(classesForRace("draenei", "coa").map((entry) => entry.id)).toContain("felsworn");
expect(classesForRace("draenei", "coa").map((entry) => entry.id)).toContain("knight-of-xoroth");
expect(classesForRace("troll", "coa").map((entry) => entry.id)).toContain("venomancer");
expect(classesForRace("troll", "coa").map((entry) => entry.id)).not.toContain("knight-of-xoroth");
expect(classesForRace("goblin", "wow")).toHaveLength(10);
expect(classesForRace("goblin", "coa")).toHaveLength(21);
expect(classesForRace("human-cultist")).toHaveLength(31);
expect(supportedGenders("vrykul")).toEqual(["male"]);
expect(supportedGenders("earthen")).toEqual(["male", "female"]);
});
+41 -25
View File
@@ -408,27 +408,27 @@ export const CLASSES: readonly ClassDefinition[] = [
{ id: "warlock", dbcId: 9, name: "Warlock", role: "Damage", sigil: "WL", color: "#9482c9", description: "Fel caster commanding curses and summoned demons.", mode: "classic", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Affliction", "Demonology", "Destruction"], archetype: "warlock" },
{ id: "druid", dbcId: 11, name: "Druid", role: "Tank / Healer / Damage", sigil: "DU", color: "#ff7d0a", description: "Shapeshifter drawing power from the wilds.", mode: "classic", armor: "leather", primaryStat: "agility", resourceSummary: "Mana", specializations: ["Balance", "Feral", "Restoration"], archetype: "druid" },
{ id: "barbarian", dbcId: 12, name: "Barbarian", role: "Damage / Support", sigil: "BA", color: "#8a3303", description: "A brutal melee combatant who cleaves foes and rallies ancestral power.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy", specializations: ["Brutality", "Headhunting", "Ancestry"], archetype: "rogue" },
{ id: "witch-doctor", dbcId: 13, name: "Witch Doctor", role: "Healer / Damage", sigil: "WD", color: "#6eff00", description: "A shadowhunter who mixes hexes, brews, wards, and restorative voodoo.", mode: "conquest", armor: "leather", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Shadowhunting", "Voodoo", "Brewing"], archetype: "shaman" },
{ id: "felsworn", dbcId: 14, name: "Felsworn", role: "Damage / Tank", sigil: "FE", color: "#a330c9", description: "A demon-touched fighter who turns fel fury into relentless offense and defense.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Felfury", specializations: ["Slayer", "Infernal", "Tyrant"], archetype: "rogue" },
{ id: "witch-hunter", dbcId: 15, name: "Witch Hunter", role: "Damage / Tank", sigil: "WH", color: "#abd473", description: "An agile inquisitor wielding blades, firearms, traps, and forbidden shadow.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Boltslinger", "Houndmaster", "Inquisition", "Black Knight"], archetype: "hunter" },
{ id: "stormbringer", dbcId: 16, name: "Stormbringer", role: "Damage / Support", sigil: "ST", color: "#0070de", description: "An elemental conduit who commands lightning, wind, and violent storms.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Static", specializations: ["Maelstrom", "Lightning", "Wind"], archetype: "shaman" },
{ id: "knight-of-xoroth", dbcId: 17, name: "Knight of Xoroth", role: "Tank / Damage", sigil: "KX", color: "#d64747", description: "A demonic plate fighter fueled by rage, deathfire, and hellish summons.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Rage / Deathfire", specializations: ["Hellfire", "Defiance", "War"], archetype: "death-knight" },
{ id: "guardian", dbcId: 18, name: "Guardian", role: "Tank / Damage / Support", sigil: "GU", color: "#c79c6e", description: "A sword-and-board leader who blocks attacks and inspires allies with banners.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Energy", specializations: ["Gladiator", "Inspiration", "Vanguard"], archetype: "warrior" },
{ id: "templar", dbcId: 19, name: "Templar", role: "Damage / Tank", sigil: "TE", color: "#00ff99", description: "A flowing holy martial artist who chains strikes into powerful combos.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy / Holy Runes", specializations: ["Zealot", "Oathkeeper", "Crusader"], archetype: "rogue" },
{ id: "bloodmage", dbcId: 20, name: "Bloodmage", role: "Damage / Healer / Tank", sigil: "BM", color: "#cc9900", description: "A sanguine spellblade who spends vitality and embraces the worgen curse.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Health", specializations: ["Sanguine", "Accursed", "Eternal", "Fleshweaver"], archetype: "druid" },
{ id: "ranger", dbcId: 21, name: "Ranger", role: "Damage / Support", sigil: "RA", color: "#fff569", description: "A mobile skirmisher equally dangerous with bows or paired melee weapons.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Focus / Archery Points", specializations: ["Archery", "Brigand", "Farstrider"], archetype: "hunter" },
{ id: "chronomancer", dbcId: 22, name: "Chronomancer", role: "Healer / Damage", sigil: "CH", color: "#f2e699", description: "A rule-bending caster who reverses wounds and warps order, chaos, and time.", mode: "conquest", armor: "cloth", primaryStat: "spirit", resourceSummary: "Mana", specializations: ["Infinite", "Time", "Artificer"], archetype: "priest" },
{ id: "necromancer", dbcId: 23, name: "Necromancer", role: "Damage", sigil: "NE", color: "#8787ed", description: "A master of frost, plague, and permanent undead minions.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runic Power", specializations: ["Death", "Rime", "Animation"], archetype: "warlock" },
{ id: "pyromancer", dbcId: 24, name: "Pyromancer", role: "Damage / Support", sigil: "PY", color: "#ff300f", description: "A fire specialist who incinerates foes, cauterizes allies, and channels dragons.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Incineration", "Flameweaving", "Draconic"], archetype: "mage" },
{ id: "cultist", dbcId: 25, name: "Cultist", role: "Healer / Damage / Tank", sigil: "CU", color: "#e0c7ff", description: "A versatile servant of the Old Gods wielding eldritch spells and void blades.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Mana / Insanity", specializations: ["Godblade", "Corruption", "Heretic", "Dreadnought"], archetype: "paladin" },
{ id: "starcaller", dbcId: 26, name: "Starcaller", role: "Tank / Damage / Healer", sigil: "SC", color: "#b5ffff", description: "An astral warrior who invokes Elune through blades, bows, and lunar magic.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Moon Guard", "Sentinel", "Moon Priest", "Warden"], archetype: "paladin" },
{ id: "sun-cleric", dbcId: 27, name: "Sun Cleric", role: "Damage / Healer / Tank", sigil: "SU", color: "#f58cba", description: "A solar champion who heals with warmth and burns enemies with holy fire.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana / Rage / Solar Power", specializations: ["Piety", "Blessings", "Valkyrie", "Seraphim"], archetype: "paladin" },
{ id: "tinker", dbcId: 28, name: "Tinker", role: "Damage / Healer", sigil: "TI", color: "#a3a3a3", description: "An inventor armed with guns, ammunition, turrets, gadgets, and mechanical allies.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Energy / Ammunition", specializations: ["Demolition", "Invention", "Mechanics"], archetype: "hunter" },
{ id: "venomancer", dbcId: 29, name: "Venomancer", role: "Tank / Damage / Healer", sigil: "VE", color: "#ff7d0a", description: "A shapeshifting toxin master who spreads venom and hardens into insect forms.", mode: "conquest", armor: "mail", primaryStat: "intellect", resourceSummary: "Mana / Rage", specializations: ["Venom", "Stalking", "Fortitude", "Vizier"], archetype: "shaman" },
{ id: "reaper", dbcId: 30, name: "Reaper", role: "Damage / Tank", sigil: "RE", color: "#c41f3b", description: "A soul-harvesting warrior who moves like a spectre and dominates the dead.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Mana / Souls", specializations: ["Harvest", "Soul", "Domination"], archetype: "death-knight" },
{ id: "primalist", dbcId: 31, name: "Primalist", role: "Damage / Tank / Healer", sigil: "PR", color: "#0d2ed6", description: "A primal shapeshifter who calls beasts, stone, magma, and the Earthmother.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Primal", "Geomancy", "Life", "Mountain King"], archetype: "druid" },
{ id: "runemaster", dbcId: 32, name: "Runemaster", role: "Damage / Tank", sigil: "RU", color: "#40c7eb", description: "A rune-inscribing battlemage who bends elements, portals, and spellblades.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runes", specializations: ["Runic", "Arcane", "Riftblade"], archetype: "mage" },
{ id: "barbarian", categoryId: "coa", dbcId: 12, name: "Barbarian", role: "Damage / Support", sigil: "BA", color: "#8a3303", description: "A brutal melee combatant who cleaves foes and rallies ancestral power.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy", specializations: ["Brutality", "Headhunting", "Ancestry"], archetype: "rogue" },
{ id: "witch-doctor", categoryId: "coa", dbcId: 13, name: "Witch Doctor", role: "Healer / Damage", sigil: "WD", color: "#6eff00", description: "A shadowhunter who mixes hexes, brews, wards, and restorative voodoo.", mode: "conquest", armor: "leather", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Shadowhunting", "Voodoo", "Brewing"], archetype: "shaman" },
{ id: "felsworn", categoryId: "coa", dbcId: 14, name: "Felsworn", role: "Damage / Tank", sigil: "FE", color: "#a330c9", description: "A demon-touched fighter who turns fel fury into relentless offense and defense.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Felfury", specializations: ["Slayer", "Infernal", "Tyrant"], archetype: "rogue" },
{ id: "witch-hunter", categoryId: "coa", dbcId: 15, name: "Witch Hunter", role: "Damage / Tank", sigil: "WH", color: "#abd473", description: "An agile inquisitor wielding blades, firearms, traps, and forbidden shadow.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Boltslinger", "Houndmaster", "Inquisition", "Black Knight"], archetype: "hunter" },
{ id: "stormbringer", categoryId: "coa", dbcId: 16, name: "Stormbringer", role: "Damage / Support", sigil: "ST", color: "#0070de", description: "An elemental conduit who commands lightning, wind, and violent storms.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Static", specializations: ["Maelstrom", "Lightning", "Wind"], archetype: "shaman" },
{ id: "knight-of-xoroth", categoryId: "coa", dbcId: 17, name: "Knight of Xoroth", role: "Tank / Damage", sigil: "KX", color: "#d64747", description: "A demonic plate fighter fueled by rage, deathfire, and hellish summons.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Rage / Deathfire", specializations: ["Hellfire", "Defiance", "War"], archetype: "death-knight" },
{ id: "guardian", categoryId: "coa", dbcId: 18, name: "Guardian", role: "Tank / Damage / Support", sigil: "GU", color: "#c79c6e", description: "A sword-and-board leader who blocks attacks and inspires allies with banners.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Energy", specializations: ["Gladiator", "Inspiration", "Vanguard"], archetype: "warrior" },
{ id: "templar", categoryId: "coa", dbcId: 19, name: "Templar", role: "Damage / Tank", sigil: "TE", color: "#00ff99", description: "A flowing holy martial artist who chains strikes into powerful combos.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy / Holy Runes", specializations: ["Zealot", "Oathkeeper", "Crusader"], archetype: "rogue" },
{ id: "bloodmage", categoryId: "coa", dbcId: 20, name: "Bloodmage", role: "Damage / Healer / Tank", sigil: "BM", color: "#cc9900", description: "A sanguine spellblade who spends vitality and embraces the worgen curse.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Health", specializations: ["Sanguine", "Accursed", "Eternal", "Fleshweaver"], archetype: "druid" },
{ id: "ranger", categoryId: "coa", dbcId: 21, name: "Ranger", role: "Damage / Support", sigil: "RA", color: "#fff569", description: "A mobile skirmisher equally dangerous with bows or paired melee weapons.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Focus / Archery Points", specializations: ["Archery", "Brigand", "Farstrider"], archetype: "hunter" },
{ id: "chronomancer", categoryId: "coa", dbcId: 22, name: "Chronomancer", role: "Healer / Damage", sigil: "CH", color: "#f2e699", description: "A rule-bending caster who reverses wounds and warps order, chaos, and time.", mode: "conquest", armor: "cloth", primaryStat: "spirit", resourceSummary: "Mana", specializations: ["Infinite", "Time", "Artificer"], archetype: "priest" },
{ id: "necromancer", categoryId: "coa", dbcId: 23, name: "Necromancer", role: "Damage", sigil: "NE", color: "#8787ed", description: "A master of frost, plague, and permanent undead minions.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runic Power", specializations: ["Death", "Rime", "Animation"], archetype: "warlock" },
{ id: "pyromancer", categoryId: "coa", dbcId: 24, name: "Pyromancer", role: "Damage / Support", sigil: "PY", color: "#ff300f", description: "A fire specialist who incinerates foes, cauterizes allies, and channels dragons.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Incineration", "Flameweaving", "Draconic"], archetype: "mage" },
{ id: "cultist", categoryId: "coa", dbcId: 25, name: "Cultist", role: "Healer / Damage / Tank", sigil: "CU", color: "#e0c7ff", description: "A versatile servant of the Old Gods wielding eldritch spells and void blades.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Mana / Insanity", specializations: ["Godblade", "Corruption", "Heretic", "Dreadnought"], archetype: "paladin" },
{ id: "starcaller", categoryId: "coa", dbcId: 26, name: "Starcaller", role: "Tank / Damage / Healer", sigil: "SC", color: "#b5ffff", description: "An astral warrior who invokes Elune through blades, bows, and lunar magic.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Moon Guard", "Sentinel", "Moon Priest", "Warden"], archetype: "paladin" },
{ id: "sun-cleric", categoryId: "coa", dbcId: 27, name: "Sun Cleric", role: "Damage / Healer / Tank", sigil: "SU", color: "#f58cba", description: "A solar champion who heals with warmth and burns enemies with holy fire.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana / Rage / Solar Power", specializations: ["Piety", "Blessings", "Valkyrie", "Seraphim"], archetype: "paladin" },
{ id: "tinker", categoryId: "coa", dbcId: 28, name: "Tinker", role: "Damage / Healer", sigil: "TI", color: "#a3a3a3", description: "An inventor armed with guns, ammunition, turrets, gadgets, and mechanical allies.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Energy / Ammunition", specializations: ["Demolition", "Invention", "Mechanics"], archetype: "hunter" },
{ id: "venomancer", categoryId: "coa", dbcId: 29, name: "Venomancer", role: "Tank / Damage / Healer", sigil: "VE", color: "#ff7d0a", description: "A shapeshifting toxin master who spreads venom and hardens into insect forms.", mode: "conquest", armor: "mail", primaryStat: "intellect", resourceSummary: "Mana / Rage", specializations: ["Venom", "Stalking", "Fortitude", "Vizier"], archetype: "shaman" },
{ id: "reaper", categoryId: "coa", dbcId: 30, name: "Reaper", role: "Damage / Tank", sigil: "RE", color: "#c41f3b", description: "A soul-harvesting warrior who moves like a spectre and dominates the dead.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Mana / Souls", specializations: ["Harvest", "Soul", "Domination"], archetype: "death-knight" },
{ id: "primalist", categoryId: "coa", dbcId: 31, name: "Primalist", role: "Damage / Tank / Healer", sigil: "PR", color: "#0d2ed6", description: "A primal shapeshifter who calls beasts, stone, magma, and the Earthmother.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Primal", "Geomancy", "Life", "Mountain King"], archetype: "druid" },
{ id: "runemaster", categoryId: "coa", dbcId: 32, name: "Runemaster", role: "Damage / Tank", sigil: "RU", color: "#40c7eb", description: "A rune-inscribing battlemage who bends elements, portals, and spellblades.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runes", specializations: ["Runic", "Arcane", "Riftblade"], archetype: "mage" },
{ id: "rom-warrior", categoryId: "rom", dbcId: 1, name: "Warrior", role: "Tank / Damage", sigil: "RW", color: "#b86b3f", description: "A hardened weapon fighter who converts incoming pressure into rage.", mode: "rom", armor: "plate", primaryStat: "strength", resourceSummary: "Rage", specializations: [], archetype: "warrior" },
{ id: "rom-scout", categoryId: "rom", dbcId: 2, name: "Scout", role: "Damage", sigil: "SC", color: "#8bae58", description: "A mobile ranged hunter who relies on focus, precision, and battlefield control.", mode: "rom", armor: "leather", primaryStat: "agility", resourceSummary: "Focus", specializations: [], archetype: "hunter" },
@@ -595,21 +595,37 @@ export function isRomClassId(classId: ClassId): classId is RomClassId {
}
export function racesForCategory(categoryId: ContentCategoryId): readonly RaceDefinition[] {
return PLAYABLE_RACES.filter((race) => (race.categoryId ?? "wow") === categoryId);
return PLAYABLE_RACES.filter((race) => {
const raceCategory = race.categoryId ?? "wow";
return raceCategory === categoryId || (categoryId === "coa" && raceCategory === "wow");
});
}
export function classesForRace(raceId: RaceId): readonly ClassDefinition[] {
export function raceSupportsCategory(raceId: RaceId, categoryId: ContentCategoryId): boolean {
const raceCategory = contentCategoryForRace(raceId);
return raceCategory === categoryId || (categoryId === "coa" && raceCategory === "wow");
}
export function classesForRace(
raceId: RaceId,
categoryId?: ContentCategoryId,
): readonly ClassDefinition[] {
const race = raceById(raceId);
if (isRomRaceId(raceId)) {
if (categoryId && categoryId !== "rom") return [];
const allowed = new Set(ROM_CLASSES_BY_RACE[raceId]);
return CLASSES.filter((characterClass) => allowed.has(characterClass.id as RomClassId));
}
if (categoryId === "rom") return [];
const classMatchesCategory = (characterClass: ClassDefinition) => (
categoryId ? contentCategoryForClass(characterClass.id) === categoryId : contentCategoryForClass(characterClass.id) !== "rom"
);
if (race.unrestrictedClasses) return CLASSES.filter((characterClass) => (
(characterClass.categoryId ?? "wow") === "wow"
classMatchesCategory(characterClass)
));
const raceDbcId = race.dbcId;
return CLASSES.filter((characterClass) => (
(characterClass.categoryId ?? "wow") === "wow"
classMatchesCategory(characterClass)
&& RACE_DBC_IDS_BY_CLASS[characterClass.id].includes(raceDbcId)
));
}
+6 -2
View File
@@ -1,4 +1,4 @@
export type ContentCategoryId = "wow" | "rom";
export type ContentCategoryId = "wow" | "rom" | "coa";
export interface ContentCategoryDefinition {
readonly id: ContentCategoryId;
@@ -6,9 +6,13 @@ export interface ContentCategoryDefinition {
readonly name: string;
}
/** Shared by character creation and dungeon selection. */
/** Character-creation categories; other catalogs may expose a supported subset. */
export const CONTENT_CATEGORIES: readonly ContentCategoryDefinition[] = Object.freeze([
{ id: "wow", label: "WoW", name: "World of Warcraft" },
{ id: "rom", label: "RoM", name: "Runes of Magic" },
{ id: "coa", label: "CoA", name: "Conquest of Azeroth" },
]);
export function contentCategoryById(id: ContentCategoryId | null | undefined): ContentCategoryDefinition {
return CONTENT_CATEGORIES.find((category) => category.id === id) ?? CONTENT_CATEGORIES[0];
}
+11 -1
View File
@@ -163,7 +163,7 @@ describe("shell store", () => {
expect(useShellStore.getState().draft.gender).toBe("male");
});
it("resets the creator to a valid draft when switching between WoW and RoM", () => {
it("resets the creator to a valid draft when switching among WoW, RoM, and CoA", () => {
useShellStore.getState().openCreator();
useShellStore.getState().updateDraft({ name: "Category Keeper", categoryId: "rom" });
expect(useShellStore.getState().draft).toMatchObject({
@@ -179,6 +179,16 @@ describe("shell store", () => {
classId: "rom-champion",
gender: "male",
});
useShellStore.getState().updateDraft({ categoryId: "coa" });
expect(useShellStore.getState().draft).toMatchObject({
name: "Categorykeep",
categoryId: "coa",
raceId: "human",
classId: "barbarian",
gender: "female",
});
useShellStore.getState().updateDraft({ classId: "priest" });
expect(useShellStore.getState().draft.classId).toBe("barbarian");
useShellStore.getState().updateDraft({ categoryId: "wow" });
expect(useShellStore.getState().draft).toMatchObject({
name: "Categorykeep",
+6 -6
View File
@@ -19,10 +19,10 @@ import {
clampAppearance,
classById,
classesForRace,
contentCategoryForRace,
createDefaultAppearance,
normalizeCharacterName,
randomAppearance,
raceSupportsCategory,
racesForCategory,
supportedGenders,
validateCharacterName,
@@ -100,7 +100,7 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
name: "",
categoryId,
raceId,
classId: categoryId === "rom" ? "rom-priest" : "priest",
classId: categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest",
gender: "female",
appearance: createDefaultAppearance(),
rotation: -12,
@@ -165,16 +165,16 @@ export const useShellStore = create<ShellState>((set, get) => ({
? { ...defaultDraft(patch.categoryId), name: state.draft.name, rotation: state.draft.rotation }
: { ...state.draft, ...patch };
if (patch.name !== undefined) next.name = normalizeCharacterName(patch.name);
if (contentCategoryForRace(next.raceId) !== next.categoryId) {
if (!raceSupportsCategory(next.raceId, next.categoryId)) {
const targetRace = racesForCategory(next.categoryId)[0];
next.raceId = targetRace.id;
next.classId = classesForRace(targetRace.id)[0].id;
next.classId = classesForRace(targetRace.id, next.categoryId)[0].id;
next.gender = supportedGenders(targetRace.id)[0] ?? "male";
next.appearance = createDefaultAppearance();
}
const availableGenders = supportedGenders(next.raceId);
if (!availableGenders.includes(next.gender)) next.gender = availableGenders[0] ?? "male";
const availableClasses = classesForRace(next.raceId);
const availableClasses = classesForRace(next.raceId, next.categoryId);
if (!availableClasses.some((definition) => definition.id === next.classId)) {
next.classId = availableClasses[0].id;
}
@@ -201,7 +201,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ notice: nameError });
return false;
}
if (!classesForRace(draft.raceId).some((definition) => definition.id === draft.classId)) {
if (!classesForRace(draft.raceId, draft.categoryId).some((definition) => definition.id === draft.classId)) {
set({ notice: "That class is not available to the selected race." });
return false;
}
+34 -13
View File
@@ -39,9 +39,11 @@ import { resolveContentUrl } from "../content/contentManager";
import {
selectCharacterBaseClip,
selectCharacterEventClip,
selectCharacterVerticalClip,
type CharacterCombatAnimationEvent,
type CharacterBaseMotion,
} from "../game/combatAnimation";
import type { CharacterVerticalMotion } from "../game/playerJump";
import { AvatarErrorBoundary } from "./AvatarErrorBoundary";
import {
ATTACK_PRESENTATION_DURATION_SECONDS,
@@ -77,6 +79,7 @@ export interface CharacterModelProps {
identity: AvatarIdentity | null | undefined;
active?: boolean;
movingRef?: MutableRefObject<boolean>;
verticalMotionRef?: MutableRefObject<CharacterVerticalMotion>;
attackPulseRef?: MutableRefObject<number>;
animationEventRef?: MutableRefObject<CharacterCombatAnimationEvent | null>;
onReady?: () => void;
@@ -88,6 +91,8 @@ export interface CharacterModelProps {
const EMPTY_EQUIPMENT: readonly InventoryItem[] = Object.freeze([]);
type CharacterLocomotionMotion = CharacterBaseMotion | Exclude<CharacterVerticalMotion, "grounded">;
type MappedMaterial = Material & { map?: Texture | null; color?: Color };
function configureMaterial(material: Material): void {
@@ -164,6 +169,7 @@ function LoadedCharacterModel({
identity,
manifest,
movingRef,
verticalMotionRef,
attackPulseRef,
animationEventRef,
onReady,
@@ -176,6 +182,7 @@ function LoadedCharacterModel({
identity: AvatarIdentity;
manifest: AvatarManifest;
movingRef?: MutableRefObject<boolean>;
verticalMotionRef?: MutableRefObject<CharacterVerticalMotion>;
attackPulseRef?: MutableRefObject<number>;
animationEventRef?: MutableRefObject<CharacterCombatAnimationEvent | null>;
onReady?: () => void;
@@ -190,7 +197,7 @@ function LoadedCharacterModel({
const overrideAction = useRef<AnimationAction | null>(null);
const overrideTerminal = useRef(false);
const overrideLooping = useRef(false);
const currentMotion = useRef<CharacterBaseMotion | null>(null);
const currentMotion = useRef<CharacterLocomotionMotion | null>(null);
const observedAttackPulse = useRef(attackPulseRef?.current ?? 0);
const equipmentAttackPulseRef = useRef(attackPulseRef?.current ?? 0);
const observedAnimationRevision = useRef(animationEventRef?.current?.revision ?? 0);
@@ -338,19 +345,27 @@ function LoadedCharacterModel({
?? gltf.animations[0]
);
const switchMotion = (motion: CharacterBaseMotion) => {
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
if (overrideAction.current) return;
if (currentMotion.current === motion) return;
const clip = selectClip(motion);
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
? selectCharacterVerticalClip(gltf.animations, motion, moving)
: null;
const fallbackMotion: CharacterBaseMotion = moving ? "run" : "idle";
const clip = verticalSelection?.clip ?? selectClip(
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion,
);
if (!clip) return;
const loop = verticalSelection?.loop ?? true;
const next = mixer.clipAction(clip, scene);
if (currentAction.current !== next) {
next.enabled = true;
next.clampWhenFinished = false;
next.setLoop(LoopRepeat, Number.POSITIVE_INFINITY).reset().fadeIn(0.16).play();
currentAction.current?.fadeOut(0.16);
currentAction.current = next;
}
next.enabled = true;
next.clampWhenFinished = !loop;
next.setLoop(loop ? LoopRepeat : LoopOnce, loop ? Number.POSITIVE_INFINITY : 1)
.reset()
.fadeIn(0.16)
.play();
if (currentAction.current !== next) currentAction.current?.fadeOut(0.16);
currentAction.current = next;
currentMotion.current = motion;
};
@@ -415,7 +430,7 @@ function LoadedCharacterModel({
};
useEffect(() => {
switchMotion("idle");
switchMotion("idle", false);
return () => {
mixer.stopAllAction();
mixer.uncacheRoot(scene);
@@ -439,6 +454,10 @@ function LoadedCharacterModel({
useFrame((_, delta) => {
if (!active) return;
const moving = !preview && Boolean(movingRef?.current);
const verticalMotion = preview ? "grounded" : verticalMotionRef?.current ?? "grounded";
const locomotionMotion: CharacterLocomotionMotion = verticalMotion === "grounded"
? moving ? "run" : "idle"
: verticalMotion;
const nextAnimationEvent = animationEventRef?.current;
if (nextAnimationEvent && nextAnimationEvent.revision !== observedAnimationRevision.current) {
observedAnimationRevision.current = nextAnimationEvent.revision;
@@ -455,7 +474,7 @@ function LoadedCharacterModel({
playAnimationEvent({ revision: nextAttackPulse, kind: "attack" });
}
}
switchMotion(moving ? "run" : "idle");
switchMotion(locomotionMotion, moving);
const safeDelta = Math.min(delta, 0.05);
mixer.update(safeDelta);
@@ -468,7 +487,7 @@ function LoadedCharacterModel({
&& !overrideLooping.current
&& !overrideAction.current.isRunning()
) clearOverride();
switchMotion(moving ? "run" : "idle");
switchMotion(locomotionMotion, moving);
const swing = attackSwingAt(attackElapsed.current);
attackElapsed.current = Math.min(
ATTACK_PRESENTATION_DURATION_SECONDS,
@@ -514,6 +533,7 @@ export function CharacterModel({
identity,
active = true,
movingRef,
verticalMotionRef,
attackPulseRef,
animationEventRef,
onReady,
@@ -546,6 +566,7 @@ export function CharacterModel({
identity={identity}
manifest={manifest}
movingRef={movingRef}
verticalMotionRef={verticalMotionRef}
attackPulseRef={attackPulseRef}
animationEventRef={animationEventRef}
onReady={onReady}
+27
View File
@@ -5,6 +5,7 @@ import {
parseWoWAnimationName,
selectCharacterBaseClip,
selectCharacterEventClip,
selectCharacterVerticalClip,
selectWoWAnimationVariation,
} from "./combatAnimation";
@@ -20,6 +21,10 @@ const clips = [
{ name: "Death (ID 1 variation 0)" },
{ name: "AttackUnarmed (ID 16 variation 0)" },
{ name: "AttackUnarmed (ID 16 variation 1)" },
{ name: "JumpStart (ID 37 variation 0)" },
{ name: "Fall (ID 40 variation 0)" },
{ name: "JumpEnd (ID 39 variation 0)" },
{ name: "JumpLandRun (ID 133 variation 0)" },
];
describe("WoW character animation selection", () => {
@@ -45,6 +50,19 @@ describe("WoW character animation selection", () => {
expect(clipsForAnimationIds(clips, [57, 17])).toEqual([clips[4], clips[3]]);
});
it("selects native rising, falling, and landing locomotion", () => {
expect(selectCharacterVerticalClip(clips, "rising", false)).toMatchObject({
clip: clips[11],
loop: false,
});
expect(selectCharacterVerticalClip(clips, "falling", false)).toMatchObject({
clip: clips[12],
loop: true,
});
expect(selectCharacterVerticalClip(clips, "landing", false)?.clip).toBe(clips[13]);
expect(selectCharacterVerticalClip(clips, "landing", true)?.clip).toBe(clips[14]);
});
it("maps hard-cast lifecycle events to ready and release clips", () => {
expect(selectCharacterEventClip(clips, "priest", {
revision: 1,
@@ -79,6 +97,9 @@ describe("WoW character animation selection", () => {
{ name: "Action - buff01" },
{ name: "Wound - hurt" },
{ name: "Death - death" },
{ name: "Action - jump_up" },
{ name: "Action - jump_down" },
{ name: "Action - jump_end_run" },
];
expect(selectCharacterEventClip(runewakerClips, "rom-mage", {
@@ -114,5 +135,11 @@ describe("WoW character animation selection", () => {
revision: 6,
kind: "death",
})).toMatchObject({ clip: runewakerClips[9], terminal: true, native: true });
expect(selectCharacterVerticalClip(runewakerClips, "rising", false)?.clip)
.toBe(runewakerClips[10]);
expect(selectCharacterVerticalClip(runewakerClips, "falling", false)?.clip)
.toBe(runewakerClips[11]);
expect(selectCharacterVerticalClip(runewakerClips, "landing", true)?.clip)
.toBe(runewakerClips[12]);
});
});
+30
View File
@@ -1,6 +1,7 @@
import { baseClassFor, type BaseClassId, type ClassId } from "../app/characterCatalog";
import type { AbilityDefinition } from "./abilityCatalog";
import { abilityAnimationById } from "./abilityAnimationLookup";
import type { CharacterVerticalMotion } from "./playerJump";
export type CharacterCombatAnimationKind =
| "ability-start"
@@ -112,6 +113,35 @@ export function selectCharacterBaseClip<TClip extends NamedAnimationClip>(
return selectWoWAnimationVariation(clips, ids);
}
export function selectCharacterVerticalClip<TClip extends NamedAnimationClip>(
clips: readonly TClip[],
motion: Exclude<CharacterVerticalMotion, "grounded">,
moving: boolean,
): CharacterClipSelection<TClip> | null {
const ids = motion === "rising"
? [37, 38, 40]
: motion === "falling"
? [40, 38, 37]
: moving
? [133, 39, 38]
: [39, 133, 38];
const runewakerNames = motion === "rising"
? ["Action - jump_up", "Action - jump_loop"]
: motion === "falling"
? ["Action - jump_down", "Action - jump_loop"]
: moving
? ["Action - jump_end_run", "Action - jump_end_back"]
: ["Action - jump_end_back", "Action - jump_end_run"];
const clip = selectWoWAnimationVariation(clips, ids)
?? selectRunewakerClipVariation(clips, runewakerNames);
return clip ? {
clip,
loop: motion === "falling",
terminal: false,
native: true,
} : null;
}
function isMeleeAbility(ability: AbilityDefinition | null, classId: ClassId): boolean {
if (!ability) return MELEE_CLASSES.has(baseClassFor(classId));
if (ability.range.max <= 4) return true;
+12 -1
View File
@@ -38,7 +38,13 @@ describe("named aura combat runtime", () => {
sourceId: PLAYER_AGGRO_ID,
targetId: PLAYER_AGGRO_ID,
expiresAt: now + 1_800_000,
definition: { disposition: "buff", dispelCategory: "magic" },
definition: {
disposition: "buff",
dispelCategory: "magic",
icon: useCombatStore.getState().abilities.find((ability) => (
ability.id === "wow335-priest-power-word-fortitude"
))?.icon,
},
});
useCombatStore.getState().tick(0, now + 1_800_000);
@@ -57,6 +63,11 @@ describe("named aura combat runtime", () => {
.flatMap((aura) => aura.absorbs)
.reduce((total, absorb) => total + absorb.remaining, 0);
expect(beforeAbsorb).toBeGreaterThan(0);
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
useCombatStore.getState().abilities.find((ability) => (
ability.id === "wow335-priest-power-word-shield"
))?.icon,
);
const healthDamage = useCombatStore.getState().damagePlayer(20, "shadow", 80);
const afterAbsorb = useCombatStore.getState().auras
+4
View File
@@ -133,6 +133,10 @@ export interface AuraDefinition {
readonly id: string;
readonly name: string;
readonly disposition: AuraDisposition;
/** Spell artwork used by every generic aura HUD surface. */
readonly icon?: string;
/** Keep a mechanically active aura out of the generic HUD strip when another widget owns its display. */
readonly hideFromAuraStrip?: boolean;
/** Null means the aura persists until explicitly removed. */
readonly durationMs: number | null;
readonly maxStacks?: number;
+19 -8
View File
@@ -83,6 +83,7 @@ import {
resolveResourceValue as resolveAuraResourceValue,
resolveStatValue as resolveAuraStatValue,
type ActiveAura,
type AuraDefinition,
type ProcEvent,
type TriggeredAuraProc,
} from "./combatAuras";
@@ -1464,6 +1465,18 @@ function aurasForEntity(auras: readonly ActiveAura[], entityId: string): ActiveA
return auras.filter((aura) => aura.targetId === entityId);
}
function auraDefinitionForAbility(
definition: AuraDefinition,
ability: AbilityDefinition,
durationMs = definition.durationMs,
): AuraDefinition {
return {
...definition,
icon: definition.icon ?? ability.icon,
durationMs,
};
}
function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): CharacterStats {
const stat = (value: number, ...names: string[]) => names.reduce(
(current, name) => resolveAuraStatValue(current, auras, name),
@@ -1534,7 +1547,7 @@ function syncPassiveAuras(
const ability = abilityAtLevel(catalogAbility, level, learnedRank);
return ability.effects
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
.map((effect) => effect.aura);
.map((effect) => auraDefinitionForAbility(effect.aura, ability));
});
const passiveIds = new Set(definitions.map((definition) => definition.id));
let next = current.filter((aura) => (
@@ -2125,7 +2138,7 @@ function executeTriggeredSpellInWork(
? sourceId
: targetId ?? sourceId;
work.auras = [...applyCombatAura(work.auras, {
definition: effect.aura,
definition: auraDefinitionForAbility(effect.aura, ability),
sourceId,
targetId: recipient,
now,
@@ -3156,12 +3169,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}, ability.id, now);
}
} else if (effect.kind === "apply-aura") {
const auraDefinition = effect.aura.durationMs === null
? effect.aura
: {
...effect.aura,
durationMs: talentAdjustedDuration(effect.aura.durationMs, ability, modifiers),
};
const durationMs = effect.aura.durationMs === null
? null
: talentAdjustedDuration(effect.aura.durationMs, ability, modifiers);
const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs);
const recipients = effect.recipient === "caster"
? [PLAYER_AURA_ENTITY_ID]
: ability.target === "hostile"
+7 -7
View File
@@ -1,7 +1,8 @@
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
import { CONTENT_CATEGORIES, type ContentCategoryId } from "../app/contentCategories";
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
export type DungeonCategoryId = ContentCategoryId;
const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
export interface DungeonCategory {
readonly id: DungeonCategoryId;
@@ -10,11 +11,10 @@ export interface DungeonCategory {
}
export const DUNGEON_CATEGORIES: readonly DungeonCategory[] = Object.freeze([
...CONTENT_CATEGORIES.map((category) => ({
id: category.id,
label: category.label,
fullName: category.name,
})),
...DUNGEON_CATEGORY_IDS.map((id) => {
const category = contentCategoryById(id);
return { id, label: category.label, fullName: category.name };
}),
]);
export interface DungeonCatalogEntry {
+30 -1
View File
@@ -10,6 +10,8 @@ import {
MAX_MOUSE_DELTA_PER_FRAME,
POINTER_LOCK_MOUSE_GRACE_MS,
beginMouseLookDrag,
clearJumpRequest,
consumeJumpRequest,
consumeMouseLook,
controllerCommandForToken,
createMouseLookBuffer,
@@ -19,6 +21,7 @@ import {
isDesktopCameraLookPointer,
keyboardAxesForKeys,
keyboardCommandForCode,
queueJumpRequest,
sanitizeMouseDelta,
type InputActions,
} from "./inputManager";
@@ -54,6 +57,7 @@ function inputActionSpies(blocked = false): InputActions {
afterEach(() => {
endMouseLookDrag();
clearJumpRequest();
resetControllerState();
vi.unstubAllGlobals();
});
@@ -89,7 +93,7 @@ describe("gameplay input bindings", () => {
it("maps Thor controls without stealing Select from display routing", () => {
expect(controllerCommandForToken("Button9")).toBe("pause");
expect(controllerCommandForToken("Button11")).toBe("target-next");
expect(controllerCommandForToken("Button10")).toBe("target-clear");
expect(controllerCommandForToken("Button10")).toBe("jump");
expect(controllerCommandForToken("Button12")).toBe("party-previous");
expect(controllerCommandForToken("Button13")).toBe("party-next");
expect(controllerCommandForToken("Button4")).toBeNull();
@@ -145,6 +149,31 @@ describe("gameplay input bindings", () => {
expect(keyboardCommandForCode("Digit8")).toBeNull();
});
it("queues Space and L3 jump presses once and blocks them behind menus", () => {
expect(keyboardCommandForCode("Space")).toBe("jump");
queueJumpRequest(25);
expect(consumeJumpRequest()).toBe(25);
expect(consumeJumpRequest()).toBeNull();
stubInputEventTargets();
const nextActions = inputActionSpies();
const blocked = nextActions.gameplayActionsBlocked as ReturnType<typeof vi.fn>;
const dispose = installInput(nextActions);
emitControllerToken({ token: "Button10", repeat: false, pressed: true });
expect(consumeJumpRequest()).not.toBeNull();
emitControllerToken({ token: "Button10", repeat: true, pressed: true });
expect(consumeJumpRequest()).toBeNull();
blocked.mockReturnValue(true);
emitControllerToken({ token: "Button10", repeat: false, pressed: false });
emitControllerToken({ token: "Button10", repeat: false, pressed: true });
expect(consumeJumpRequest()).toBeNull();
dispose();
});
it("uses WASD for movement and arrow keys for camera look", () => {
expect(keyboardAxesForKeys(new Set(["KeyW", "KeyD"]))).toEqual({
moveX: 1,
+21 -1
View File
@@ -42,6 +42,7 @@ export interface InputActions {
const keys = new Set<string>();
let actions: InputActions | null = null;
let jumpRequestAt: number | null = null;
// Pointer-lock implementations can emit a short burst of stale, very large
// movement events while the cursor is being recentered. Some Android WebViews have also been
@@ -69,6 +70,20 @@ function monotonicNow(): number {
return Date.now();
}
export function queueJumpRequest(nowMs = monotonicNow()): void {
if (Number.isFinite(nowMs)) jumpRequestAt = nowMs;
}
export function consumeJumpRequest(): number | null {
const requestedAt = jumpRequestAt;
jumpRequestAt = null;
return requestedAt;
}
export function clearJumpRequest(): void {
jumpRequestAt = null;
}
export function createMouseLookBuffer(now: () => number = monotonicNow) {
let x = 0;
let y = 0;
@@ -161,12 +176,13 @@ export type GameplayInputCommand =
| "party-defend"
| "party-stop"
| "party-recall"
| "jump"
| "target-clear";
export function controllerCommandForToken(token: string): GameplayInputCommand | null {
if (token === "Button9" || token === CONTROLLER_SYSTEM_BACK_TOKEN) return "pause";
if (token === "Button11") return "target-next";
if (token === "Button10") return "target-clear";
if (token === "Button10") return "jump";
if (token === "Button12") return "party-previous";
if (token === "Button13") return "party-next";
return null;
@@ -183,6 +199,7 @@ export function keyboardCommandForCode(code: string, shiftKey = false): Gameplay
if (code === "F2") return "party-defend";
if (code === "F3") return "party-stop";
if (code === "F4") return "party-recall";
if (code === "Space") return "jump";
if (code === "Tab") return shiftKey ? "target-previous" : "target-next";
return null;
}
@@ -202,6 +219,7 @@ function runCommand(command: GameplayInputCommand, nextActions: InputActions): v
else if (command === "party-defend") nextActions.setPartyCommand("defend");
else if (command === "party-stop") nextActions.setPartyCommand("stop");
else if (command === "party-recall") nextActions.recallParty();
else if (command === "jump") queueJumpRequest();
else if (command === "target-clear") nextActions.clearTarget();
}
@@ -303,6 +321,7 @@ export function installInput(nextActions: InputActions): () => void {
return;
}
if (nextActions.gameplayActionsBlocked()) return;
if (command === "jump") event.preventDefault();
if (event.code === "Tab") event.preventDefault();
runCommand(command, nextActions);
};
@@ -339,6 +358,7 @@ export function installInput(nextActions: InputActions): () => void {
keyboardModifiers.clear();
controllerL1 = false;
controllerL2 = false;
clearJumpRequest();
mouseLookDragActive = false;
previousMousePosition = null;
nextActions.setActionLayer("primary");
+98
View File
@@ -8,10 +8,17 @@ import {
partyCombatMovementGoal,
partyFormationPosition,
partyMadeStuckProgress,
partyMovementProfile,
partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal,
partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck,
partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail,
stepPartyPosition,
synchronizePartyBreadcrumbCursor,
} from "./partyMovement";
describe("party world movement", () => {
@@ -38,6 +45,89 @@ describe("party world movement", () => {
expect(out).toEqual([12, 2, 17]);
});
it("builds stable and distinct bounded movement personalities", () => {
const first = partyMovementProfile("member-a", 0);
const repeat = partyMovementProfile("member-a", 0);
const second = partyMovementProfile("member-b", 1);
expect(first).toEqual(repeat);
expect(second).not.toEqual(first);
for (const profile of [first, second]) {
expect(Math.abs(profile.followDistanceJitter)).toBeLessThanOrEqual(0.35);
expect(Math.abs(profile.lateralOffset)).toBeLessThanOrEqual(0.65);
expect(profile.comfortSlack).toBeGreaterThanOrEqual(0.35);
expect(profile.comfortSlack).toBeLessThanOrEqual(0.85);
expect(profile.speedMultiplier).toBeGreaterThanOrEqual(0.94);
expect(profile.speedMultiplier).toBeLessThanOrEqual(1.06);
expect(Math.abs(profile.combatAngleOffset)).toBeLessThanOrEqual(50 * Math.PI / 180);
}
});
it("places natural followers inside personal spacing and movement bands", () => {
const profile = partyMovementProfile("member-c", 2);
const out: [number, number, number] = [0, 0, 0];
partyNaturalFollowPosition([10, 2, 20], 0, 1, 3.75, profile, 5_000, out);
expect(out[1]).toBe(2);
expect(Math.abs(out[0] - 10)).toBeLessThanOrEqual(0.83);
expect(20 - out[2]).toBeGreaterThanOrEqual(3.4);
expect(20 - out[2]).toBeLessThanOrEqual(4.1);
expect(partyShouldMoveToComfortTarget(out, out, false, profile.comfortSlack)).toBe(false);
expect(partyShouldMoveToComfortTarget([0, 0, 0], out, false, profile.comfortSlack)).toBe(true);
});
it("keeps personalized combat bands inside role-safe bounds", () => {
const profiles = [
partyMovementProfile("ranged-a", 1),
partyMovementProfile("ranged-b", 2),
];
for (const profile of profiles) {
const ranged = personalizedPartyCombatRangeBand({
minimum: 10,
preferred: 14.5,
maximum: 18.1,
}, profile);
expect(ranged.minimum).toBeGreaterThanOrEqual(9);
expect(ranged.maximum).toBeLessThanOrEqual(18.1);
expect(ranged.preferred).toBeGreaterThan(ranged.minimum);
expect(ranged.preferred).toBeLessThan(ranged.maximum);
const melee = personalizedPartyCombatRangeBand({
minimum: 0,
preferred: 3.3,
maximum: 3.55,
}, profile);
expect(melee.minimum).toBe(0);
expect(melee.maximum).toBeLessThanOrEqual(3.55);
expect(melee.preferred).toBeLessThan(melee.maximum);
}
});
it("uses a stable combat bearing and separates overlapping allies", () => {
const out: [number, number, number] = [0, 0, 0];
expect(partyPersonalizedCombatMovementGoal(
[20, 0, 0],
[0, 0, 0],
true,
{ minimum: 10, preferred: 14, maximum: 18 },
Math.PI / 2,
false,
out,
)).toBe("approach");
expect(out[0]).toBeCloseTo(14);
expect(out[1]).toBe(0);
expect(out[2]).toBeCloseTo(0);
const separated: [number, number, number] = [0, 0, 0];
expect(partySeparationGoal(
[1, 0, 1],
[2, 0, 2],
[[1, 0, 1]],
Math.PI / 2,
separated,
)).toBe(true);
expect(separated[0]).toBeGreaterThan(2);
expect(separated[2]).toBeCloseTo(2);
});
it("recalls only distant allies that should be moving and stopped making progress", () => {
expect(partyMadeStuckProgress([0, 0, 0], [0.45, 4, 0])).toBe(true);
expect(partyMadeStuckProgress([0, 0, 0], [0.44, 0, 0])).toBe(false);
@@ -168,6 +258,14 @@ describe("party world movement", () => {
expect(out).toEqual([20, 3, 4]);
});
it("synchronizes direct followers to the matching safe trail point", () => {
const trail = createPartyBreadcrumbTrail();
for (let x = 0; x <= 6; x += 1) appendPartyBreadcrumb(trail, [x, 0, 0], 0.1, 20);
const cursor = createPartyBreadcrumbCursor();
synchronizePartyBreadcrumbCursor(trail, cursor, 2);
expect(cursor.breadcrumbId).toBe(trail.points.find((point) => point.position[0] === 4)?.id);
});
it("lets a distant follower rejoin a trail before it is long enough for formation spacing", () => {
const trail = createPartyBreadcrumbTrail();
const cursor = createPartyBreadcrumbCursor();
+221 -1
View File
@@ -19,7 +19,72 @@ export interface PartyBreadcrumbCursor {
breadcrumbId: number | null;
}
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition";
export interface PartyMovementProfile {
readonly followDistanceJitter: number;
readonly lateralOffset: number;
readonly comfortSlack: number;
readonly speedMultiplier: number;
readonly swayAmplitude: number;
readonly swayPeriodMs: number;
readonly swayPhase: number;
readonly combatAngleOffset: number;
readonly combatRangeBias: number;
}
export interface PartyCombatRangeLike {
readonly minimum: number;
readonly preferred: number;
readonly maximum: number;
}
function stableHash(value: string): number {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function hashUnit(value: string): number {
return stableHash(value) / 0xffff_ffff;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
/** Stable per-character preferences keep motion reproducible without looking cloned. */
export function partyMovementProfile(memberId: string, livingRank: number): PartyMovementProfile {
const rank = Math.max(0, Math.floor(Number.isFinite(livingRank) ? livingRank : 0));
const prefix = `${memberId}|${rank}`;
const lateralMagnitude = rank === 0
? hashUnit(`${prefix}|leader-lateral`) * 0.3
: 0.35 + hashUnit(`${prefix}|lateral`) * 0.3;
const lateralSign = rank === 0
? hashUnit(`${prefix}|leader-side`) < 0.5 ? -1 : 1
: rank % 2 === 1 ? -1 : 1;
const angleSlots = [0, -35, 35, -50, 50];
const angleDegrees = clamp(
(angleSlots[rank % angleSlots.length] ?? 0)
+ (hashUnit(`${prefix}|angle-jitter`) - 0.5) * 10,
-50,
50,
);
return {
followDistanceJitter: (hashUnit(`${prefix}|distance`) - 0.5) * 0.7,
lateralOffset: lateralSign * lateralMagnitude,
comfortSlack: 0.35 + hashUnit(`${prefix}|comfort`) * 0.5,
speedMultiplier: 0.94 + hashUnit(`${prefix}|speed`) * 0.12,
swayAmplitude: 0.08 + hashUnit(`${prefix}|sway-amplitude`) * 0.1,
swayPeriodMs: 6_000 + hashUnit(`${prefix}|sway-period`) * 4_000,
swayPhase: hashUnit(`${prefix}|sway-phase`) * Math.PI * 2,
combatAngleOffset: angleDegrees * Math.PI / 180,
combatRangeBias: hashUnit(`${prefix}|combat-range`) * 2 - 1,
};
}
export const PARTY_STUCK_RECALL_DISTANCE = 12;
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
@@ -66,6 +131,25 @@ export function createPartyBreadcrumbCursor(): PartyBreadcrumbCursor {
return { breadcrumbId: null };
}
export function synchronizePartyBreadcrumbCursor(
trail: PartyBreadcrumbTrail,
cursor: PartyBreadcrumbCursor,
trailingDistance: number,
): void {
const latest = trail.points[trail.points.length - 1];
if (!latest) {
cursor.breadcrumbId = null;
return;
}
const maximumDistance = latest.distance - Math.max(0, trailingDistance);
let target = trail.points[0];
for (const point of trail.points) {
if (point.distance > maximumDistance + EPSILON) break;
target = point;
}
cursor.breadcrumbId = target.id;
}
export function resetPartyBreadcrumbTrail(
trail: PartyBreadcrumbTrail,
position?: PartyWorldPosition,
@@ -271,6 +355,114 @@ export function partyFormationPosition(
out[2] = anchor[2] + rightZ * lateral - normalizedForwardZ * trailing;
}
export function partyNaturalFollowPosition(
anchor: PartyWorldPosition,
forwardX: number,
forwardZ: number,
baseTrailingDistance: number,
profile: PartyMovementProfile,
nowMs: number,
out: MutablePartyWorldPosition,
): void {
const trailing = Math.max(0.9, baseTrailingDistance + profile.followDistanceJitter);
const safeNow = Number.isFinite(nowMs) ? nowMs : 0;
const sway = Math.sin(
safeNow / Math.max(1, profile.swayPeriodMs) * Math.PI * 2 + profile.swayPhase,
) * profile.swayAmplitude;
partyFormationPosition(
anchor,
forwardX,
forwardZ,
profile.lateralOffset + sway,
trailing,
out,
);
}
/** Start outside a personal comfort radius, then finish close enough to avoid jitter. */
export function partyShouldMoveToComfortTarget(
current: PartyWorldPosition,
target: PartyWorldPosition,
wasMoving: boolean,
comfortSlack: number,
): boolean {
const distance = Math.hypot(
target[0] - current[0],
target[1] - current[1],
target[2] - current[2],
);
if (!Number.isFinite(distance)) return false;
const startDistance = Math.max(0.18, Number.isFinite(comfortSlack) ? comfortSlack : 0.35);
return distance > (wasMoving ? 0.18 : startDistance);
}
export function personalizedPartyCombatRangeBand(
base: PartyCombatRangeLike,
profile: PartyMovementProfile,
): PartyCombatRangeLike {
if (base.minimum > 0) {
const minimum = clamp(base.minimum + profile.combatRangeBias * 0.75, 9, 11);
const maximum = clamp(base.maximum + profile.combatRangeBias * 0.35, 17, 18.1);
return {
minimum,
preferred: clamp(
base.preferred + profile.combatRangeBias * 1.25,
minimum + 0.75,
maximum - 0.5,
),
maximum,
};
}
const maximum = Math.max(2.4, base.maximum - Math.abs(profile.combatRangeBias) * 0.2);
return {
minimum: 0,
preferred: clamp(
base.preferred + profile.combatRangeBias * 0.3,
2.35,
maximum - 0.12,
),
maximum,
};
}
/** Adds a bounded local repulsion so allies do not occupy the same point. */
export function partySeparationGoal(
current: PartyWorldPosition,
desired: PartyWorldPosition,
peers: readonly PartyWorldPosition[],
fallbackAngle: number,
out: MutablePartyWorldPosition,
separationDistance = 0.85,
maximumCorrection = 0.35,
): boolean {
let offsetX = 0;
let offsetZ = 0;
const safeSeparation = Math.max(EPSILON, separationDistance);
for (const peer of peers) {
const dx = current[0] - peer[0];
const dz = current[2] - peer[2];
const distance = Math.hypot(dx, dz);
if (!Number.isFinite(distance) || distance >= safeSeparation) continue;
const weight = (safeSeparation - distance) / safeSeparation;
if (distance <= EPSILON) {
offsetX += Math.sin(fallbackAngle) * weight;
offsetZ += Math.cos(fallbackAngle) * weight;
} else {
offsetX += dx / distance * weight;
offsetZ += dz / distance * weight;
}
}
const length = Math.hypot(offsetX, offsetZ);
out[0] = desired[0];
out[1] = desired[1];
out[2] = desired[2];
if (length <= EPSILON) return false;
const correction = Math.min(Math.max(0, maximumCorrection), length);
out[0] += offsetX / length * correction;
out[2] += offsetZ / length * correction;
return true;
}
/**
* Chooses an individual combat destination. Melee only closes distance;
* ranged attackers also back out when a target collapses their preferred band.
@@ -323,3 +515,31 @@ export function partyCombatMovementGoal(
out[2] = target[2] + awayZ / planarLength * preferred;
return "retreat";
}
export function partyPersonalizedCombatMovementGoal(
current: PartyWorldPosition,
target: PartyWorldPosition,
targetVisible: boolean,
rangeBand: PartyCombatRangeLike,
preferredBearing: number,
forceReposition: boolean,
out: MutablePartyWorldPosition,
): PartyCombatMovementIntent {
const intent = partyCombatMovementGoal(
current,
target,
targetVisible,
rangeBand.minimum,
rangeBand.preferred,
rangeBand.maximum,
out,
Math.sin(preferredBearing),
Math.cos(preferredBearing),
);
if (!targetVisible || (intent === "hold" && !forceReposition)) return intent;
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
out[1] = target[1];
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
return intent === "hold" ? "reposition" : intent;
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
PLAYER_GROUND_MIN_NORMAL_Y,
PLAYER_JUMP_BUFFER_MS,
PLAYER_JUMP_COYOTE_MS,
bufferPlayerJump,
cancelBufferedPlayerJump,
characterVerticalMotion,
createPlayerJumpTiming,
isWalkableGroundHit,
updatePlayerJumpTiming,
} from "./playerJump";
describe("player jump timing", () => {
it("accepts only a nearby upward-facing ground hit", () => {
expect(isWalkableGroundHit({
timeOfImpact: 0.9,
normal: { y: PLAYER_GROUND_MIN_NORMAL_Y },
}, 0.95)).toBe(true);
expect(isWalkableGroundHit({ timeOfImpact: 0.96, normal: { y: 1 } }, 0.95)).toBe(false);
expect(isWalkableGroundHit({ timeOfImpact: 0.4, normal: { y: 0.2 } }, 0.95)).toBe(false);
});
it("buffers a press shortly before landing", () => {
const timing = createPlayerJumpTiming();
bufferPlayerJump(timing, 1_000);
expect(updatePlayerJumpTiming(timing, 1_000 + PLAYER_JUMP_BUFFER_MS - 1, true, -2)).toBe(true);
});
it("allows coyote time after leaving a ledge", () => {
const timing = createPlayerJumpTiming(2_000);
bufferPlayerJump(timing, 2_000 + PLAYER_JUMP_COYOTE_MS - 1);
expect(updatePlayerJumpTiming(
timing,
2_000 + PLAYER_JUMP_COYOTE_MS - 1,
false,
-0.2,
)).toBe(true);
});
it("cancels buffered input while gameplay is blocked", () => {
const timing = createPlayerJumpTiming(2_500);
bufferPlayerJump(timing, 2_500);
cancelBufferedPlayerJump(timing);
expect(updatePlayerJumpTiming(timing, 2_500, true, 0)).toBe(false);
});
it("does not double jump until a real landing", () => {
const timing = createPlayerJumpTiming(3_000);
bufferPlayerJump(timing, 3_000);
expect(updatePlayerJumpTiming(timing, 3_000, true, 0)).toBe(true);
bufferPlayerJump(timing, 3_040);
expect(updatePlayerJumpTiming(timing, 3_040, true, 6.2)).toBe(false);
expect(updatePlayerJumpTiming(timing, 3_300, false, -2)).toBe(false);
bufferPlayerJump(timing, 3_550);
expect(updatePlayerJumpTiming(timing, 3_600, true, -1)).toBe(true);
});
it("reports rising, falling, landing, and grounded presentation phases", () => {
expect(characterVerticalMotion(false, 2, 100, 0)).toBe("rising");
expect(characterVerticalMotion(false, -0.1, 100, 0)).toBe("falling");
expect(characterVerticalMotion(true, 0, 100, 150)).toBe("landing");
expect(characterVerticalMotion(true, 0, 150, 150)).toBe("grounded");
});
});
+94
View File
@@ -0,0 +1,94 @@
export const PLAYER_JUMP_VELOCITY = 7.2;
export const PLAYER_JUMP_COYOTE_MS = 100;
export const PLAYER_JUMP_BUFFER_MS = 120;
export const PLAYER_JUMP_LANDING_UNLOCK_MS = 80;
export const PLAYER_LANDING_PRESENTATION_MS = 180;
export const PLAYER_GROUND_PROBE_DISTANCE = 0.14;
export const PLAYER_GROUND_MIN_NORMAL_Y = 0.55;
export type CharacterVerticalMotion = "grounded" | "rising" | "falling" | "landing";
export interface PlayerJumpTiming {
lastGroundedAtMs: number;
bufferedUntilMs: number;
jumpedAtMs: number;
lockedUntilLanding: boolean;
}
export interface GroundProbeHit {
readonly timeOfImpact: number;
readonly normal: { readonly y: number };
}
export function createPlayerJumpTiming(nowMs = Number.NEGATIVE_INFINITY): PlayerJumpTiming {
return {
lastGroundedAtMs: nowMs,
bufferedUntilMs: Number.NEGATIVE_INFINITY,
jumpedAtMs: Number.NEGATIVE_INFINITY,
lockedUntilLanding: false,
};
}
export function bufferPlayerJump(timing: PlayerJumpTiming, requestedAtMs: number): void {
if (!Number.isFinite(requestedAtMs)) return;
timing.bufferedUntilMs = requestedAtMs + PLAYER_JUMP_BUFFER_MS;
}
export function cancelBufferedPlayerJump(timing: PlayerJumpTiming): void {
timing.bufferedUntilMs = Number.NEGATIVE_INFINITY;
}
export function isWalkableGroundHit(
hit: GroundProbeHit | null | undefined,
maximumTimeOfImpact: number,
): boolean {
return Boolean(
hit
&& Number.isFinite(hit.timeOfImpact)
&& hit.timeOfImpact >= 0
&& hit.timeOfImpact <= maximumTimeOfImpact
&& Number.isFinite(hit.normal.y)
&& hit.normal.y >= PLAYER_GROUND_MIN_NORMAL_Y,
);
}
/**
* Updates the timing latch and consumes a buffered jump exactly once. The
* upward-velocity guard prevents the ground probe from unlocking the latch on
* the first few frames after launch while the capsule is still near the floor.
*/
export function updatePlayerJumpTiming(
timing: PlayerJumpTiming,
nowMs: number,
grounded: boolean,
verticalVelocity: number,
): boolean {
if (!Number.isFinite(nowMs)) return false;
if (grounded && verticalVelocity <= 0.5) {
timing.lastGroundedAtMs = nowMs;
if (
timing.lockedUntilLanding
&& nowMs - timing.jumpedAtMs >= PLAYER_JUMP_LANDING_UNLOCK_MS
) timing.lockedUntilLanding = false;
}
const buffered = timing.bufferedUntilMs >= nowMs;
const insideCoyoteWindow = nowMs - timing.lastGroundedAtMs <= PLAYER_JUMP_COYOTE_MS;
if (timing.lockedUntilLanding || !buffered || !insideCoyoteWindow) return false;
timing.lockedUntilLanding = true;
timing.jumpedAtMs = nowMs;
timing.bufferedUntilMs = Number.NEGATIVE_INFINITY;
return true;
}
export function characterVerticalMotion(
grounded: boolean,
verticalVelocity: number,
nowMs: number,
landingUntilMs: number,
): CharacterVerticalMotion {
if (grounded) return nowMs < landingUntilMs ? "landing" : "grounded";
return verticalVelocity > 0.15 ? "rising" : "falling";
}
+2 -2
View File
@@ -147,7 +147,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
}
}
}
}, 30_000);
}, 60_000);
it("ships every configured directly-convertible actor as native except documented pose/proxy cases", async () => {
const recipes = await Promise.all(
@@ -305,7 +305,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
poseOnly: 1,
proxy: 0,
});
}, 30_000);
}, 60_000);
it("packages every Pasper actor with a skin and original combat/locomotion clips", async () => {
const pasper = DUNGEON_DEFINITIONS.find((definition) => definition.id === "paspers-shrine");
+31
View File
@@ -57,12 +57,43 @@ describe("game store", () => {
expect(state.areaName).toContain("Entrance");
});
it("keeps a grounded navigation anchor while the physical player is airborne", () => {
useGameStore.getState().updatePlayerSnapshot({
position: [164, -73.66, 132],
yaw: -2,
grounded: true,
});
useGameStore.getState().updatePlayerSnapshot({
position: [166, -72.2, 133],
yaw: -2,
grounded: false,
});
expect(useGameStore.getState()).toMatchObject({
playerPosition: [166, -72.2, 133],
playerNavigationPosition: [164, -73.66, 132],
playerGrounded: false,
});
useGameStore.getState().updatePlayerSnapshot({
position: [167, -73.66, 134],
yaw: -2,
grounded: true,
});
expect(useGameStore.getState()).toMatchObject({
playerNavigationPosition: [167, -73.66, 134],
playerGrounded: true,
});
});
it("reset restores the authoritative transformed entrance", () => {
useGameStore.getState().setActionLayer("tertiary");
useGameStore.getState().updatePlayerSnapshot({ position: [0, 0, 0], yaw: 0, distanceDelta: 8 });
useGameStore.getState().resetAtEntrance();
const state = useGameStore.getState();
expect(state.playerPosition).toEqual(DUNGEON_MANIFEST.entrance.footPosition);
expect(state.playerNavigationPosition).toEqual(DUNGEON_MANIFEST.entrance.footPosition);
expect(state.playerGrounded).toBe(true);
expect(state.cameraYaw).toBe(DUNGEON_MANIFEST.entrance.yaw);
expect(state.distanceTravelled).toBe(0);
expect(state.actionLayer).toBe("primary");
+14 -1
View File
@@ -13,6 +13,7 @@ interface PlayerSnapshot {
position: Vector3Tuple;
yaw: number;
distanceDelta?: number;
grounded?: boolean;
}
export interface GameState {
@@ -31,6 +32,8 @@ export interface GameState {
assetStatus: AssetStatus;
assetMessage: string | null;
playerPosition: Vector3Tuple;
playerNavigationPosition: Vector3Tuple;
playerGrounded: boolean;
cameraYaw: number;
areaName: string;
distanceTravelled: number;
@@ -77,6 +80,8 @@ export const useGameStore = create<GameState>((set) => ({
assetStatus: "checking",
assetMessage: null,
playerPosition: entrance.footPosition,
playerNavigationPosition: entrance.footPosition,
playerGrounded: true,
cameraYaw: entrance.yaw,
areaName: resolveAreaNameForDungeon(initialDungeon, entrance.footPosition),
distanceTravelled: 0,
@@ -106,6 +111,8 @@ export const useGameStore = create<GameState>((set) => ({
assetStatus: "checking",
assetMessage: null,
playerPosition: activeSpawn.footPosition,
playerNavigationPosition: activeSpawn.footPosition,
playerGrounded: true,
cameraYaw: activeSpawn.yaw,
areaName: resolveAreaNameForDungeon(definition, activeSpawn.footPosition),
distanceTravelled: 0,
@@ -175,10 +182,12 @@ export const useGameStore = create<GameState>((set) => ({
if (isStaleLoadingWrite) return state;
return { assetStatus, assetMessage };
}),
updatePlayerSnapshot: ({ position, yaw, distanceDelta = 0 }) => set((state) => {
updatePlayerSnapshot: ({ position, yaw, distanceDelta = 0, grounded = true }) => set((state) => {
const definition = requireDungeonDefinition(state.activeDungeonId);
return {
playerPosition: position,
playerNavigationPosition: grounded ? position : state.playerNavigationPosition,
playerGrounded: grounded,
cameraYaw: yaw,
areaName: resolveAreaNameForDungeon(definition, position),
distanceTravelled: state.distanceTravelled + Math.max(0, distanceDelta),
@@ -193,6 +202,8 @@ export const useGameStore = create<GameState>((set) => ({
mapOpen: false,
cameraLookActive: false,
playerPosition: state.activeSpawn.footPosition,
playerNavigationPosition: state.activeSpawn.footPosition,
playerGrounded: true,
cameraYaw: state.activeSpawn.yaw,
areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition),
distanceTravelled: 0,
@@ -213,6 +224,8 @@ export const useGameStore = create<GameState>((set) => ({
mapOpen: false,
cameraLookActive: false,
playerPosition: activeEntrance.footPosition,
playerNavigationPosition: activeEntrance.footPosition,
playerGrounded: true,
cameraYaw: activeEntrance.yaw,
areaName: resolveAreaNameForDungeon(definition, activeEntrance.footPosition),
distanceTravelled: 0,
+20
View File
@@ -77,6 +77,26 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(shield.effects.some((effect) => effect.kind === "shield")).toBe(false);
});
it("keeps periodic damage and healing auras out of the generic HUD strip", () => {
const pain = byId("wow335-priest-shadow-word-pain");
const painAura = effectsOfKind(pain, "apply-aura")
.find((effect) => effect.sourceAuraType === 3);
const renew = byId("wow335-priest-renew");
const renewAura = effectsOfKind(renew, "apply-aura")
.find((effect) => effect.sourceAuraType === 8);
expect(painAura?.aura).toMatchObject({
disposition: "debuff",
hideFromAuraStrip: true,
});
expect(renewAura?.aura).toMatchObject({
disposition: "buff",
hideFromAuraStrip: true,
});
expect(effectsOfKind(pain, "dot")).toHaveLength(1);
expect(effectsOfKind(renew, "hot")).toHaveLength(1);
});
it("maps cleanse, purge, and dual-purpose dispel categories", () => {
expect(effectsOfKind(byId("wow335-paladin-cleanse"), "dispel")).toEqual(expect.arrayContaining([
expect.objectContaining({ mode: "cleanse", relationship: "friendly", categories: ["magic"] }),
+3
View File
@@ -367,6 +367,9 @@ function namedAuraForEffect(
id: `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`,
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
...([3, 8].includes(effect.auraType) && effect.periodMs > 0
? { hideFromAuraStrip: true }
: {}),
durationMs,
maxStacks: 1,
stackBehavior: "refresh",
+268 -77
View File
@@ -31,14 +31,21 @@ import {
createPartyBreadcrumbCursor,
createPartyBreadcrumbTrail,
nextPartyBreadcrumbFollowWaypoint,
partyCombatMovementGoal,
partyFormationPosition,
partyMadeStuckProgress,
partyMovementProfile,
partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal,
partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck,
partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail,
stepPartyPosition,
synchronizePartyBreadcrumbCursor,
type PartyBreadcrumbCursor,
type PartyMovementProfile,
} from "../game/partyMovement";
import {
buildPartyNavigationGraph,
@@ -82,13 +89,7 @@ const PARTY_MAX_FLOOR_DELTA = 0.55;
const PARTY_MAX_GROUND_CORRECTION = 0.85;
const EMPTY_NAVIGATION_LINKS = [] as const;
/** Lateral and trailing offsets behind the current squad anchor. */
const PARTY_FORMATION: readonly (readonly [number, number])[] = [
[0, 1.45],
[0, 2.6],
[0, 3.75],
[0, 4.9],
];
const PARTY_PLAYER_TRAILING_DISTANCE: readonly number[] = [1.45, 2.6, 3.75, 4.9];
const PARTY_ENTRANCE_FORWARD_DISTANCE: readonly number[] = [2.4, 1.8, 1.2, 0.6];
const PARTY_TRAIL_DISTANCE: readonly number[] = [0, 1.15, 2.3, 3.45];
@@ -106,6 +107,11 @@ interface PartyStuckProgress {
lastProgressAt: number;
}
interface PartyCombatAnchor {
readonly targetId: string;
readonly bearing: number;
}
type PartyAvatarMetricKey = `${RaceId}:${GenderId}`;
/** Bind-pose bounds measured from the shipped wow335a rigs, in local meters. */
@@ -418,6 +424,9 @@ function PartyActors({
const lastCommandRef = useRef(usePartyStore.getState().command);
const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision);
const stuckProgressRef = useRef(new Map<string, PartyStuckProgress>());
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
const directFollowMovingRef = useRef(new Map<string, boolean>());
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
const memberIdKey = members.map((member) => member.id).join("|");
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
const party = usePartyStore.getState();
@@ -448,6 +457,8 @@ function PartyActors({
trailLeaderIdRef.current = firstLiving?.id ?? null;
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
followerCursorsRef.current.clear();
lastActiveTargetIdRef.current = party.activeMobId;
lastLeaderWaypointRef.current = null;
@@ -473,6 +484,17 @@ function PartyActors({
for (const id of stuckProgressRef.current.keys()) {
if (!activeIds.has(id)) stuckProgressRef.current.delete(id);
}
for (const id of directFollowMovingRef.current.keys()) {
if (!activeIds.has(id)) directFollowMovingRef.current.delete(id);
}
for (const id of combatAnchorsRef.current.keys()) {
if (!activeIds.has(id)) combatAnchorsRef.current.delete(id);
}
for (const key of movementProfilesRef.current.keys()) {
if (![...activeIds].some((id) => key.startsWith(`${id}|`))) {
movementProfilesRef.current.delete(key);
}
}
const cleanups = members.map((member, index) => {
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
positionsRef.current.set(member.id, position);
@@ -534,6 +556,8 @@ function PartyActors({
trailLeaderIdRef.current = firstLiving?.id ?? null;
followerCursorsRef.current.clear();
combatRoutesRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
lastActiveTargetIdRef.current = null;
lastLeaderWaypointRef.current = null;
routeRef.current = emptyPartyRouteState();
@@ -561,6 +585,8 @@ function PartyActors({
if (leaderPosition) resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition);
followerCursorsRef.current.clear();
stuckProgressRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
}, [navigationGraph]);
// Time spent in a pause/menu must not count toward the stuck watchdog.
@@ -575,24 +601,39 @@ function PartyActors({
const party = usePartyStore.getState();
if (!party.members.length) return;
const game = useGameStore.getState();
const player = game.playerPosition;
appendPartyBreadcrumb(playerTrailRef.current, player);
const playerNavigation = game.playerNavigationPosition;
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
const routeNow = clock.elapsedTime * 1_000;
const playerTrail = playerTrailRef.current.points;
const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1];
const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2];
let playerForwardX = Math.sin(game.cameraYaw);
let playerForwardZ = Math.cos(game.cameraYaw);
if (latestPlayerTrailPoint && previousPlayerTrailPoint) {
const trailX = latestPlayerTrailPoint.position[0] - previousPlayerTrailPoint.position[0];
const trailZ = latestPlayerTrailPoint.position[2] - previousPlayerTrailPoint.position[2];
if (Math.hypot(trailX, trailZ) > 0.05) {
playerForwardX = trailX;
playerForwardZ = trailZ;
}
}
if (lastRecallRevisionRef.current !== party.recallRevision) {
lastRecallRevisionRef.current = party.recallRevision;
teleportPartyToPlayer(player, routeNow);
teleportPartyToPlayer(playerNavigation, routeNow);
return;
}
if (lastCommandRef.current !== party.command) {
lastCommandRef.current = party.command;
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
followerCursorsRef.current.clear();
const livingLeader = party.members.find((member) => member.health > 0);
const livingLeaderPosition = livingLeader ? positionsRef.current.get(livingLeader.id) : null;
resetPartyBreadcrumbTrail(
leaderTrailRef.current,
livingLeaderPosition ?? player,
livingLeaderPosition ?? playerNavigation,
);
lastLeaderWaypointRef.current = null;
}
@@ -602,9 +643,29 @@ function PartyActors({
const desired: MutablePartyWorldPosition = [0, 0, 0];
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0];
const separatedDesired: MutablePartyWorldPosition = [0, 0, 0];
const livingIndices = party.members
.map((member, index) => member.health > 0 ? index : -1)
.filter((index) => index >= 0);
const livingIds = new Set(livingIndices.map((index) => party.members[index].id));
const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => {
const livingRank = Math.max(0, livingIndices.indexOf(memberIndex));
const key = `${memberId}|${livingRank}`;
let profile = movementProfilesRef.current.get(key);
if (!profile) {
profile = partyMovementProfile(memberId, livingRank);
movementProfilesRef.current.set(key, profile);
}
return profile;
};
const peerPositionsFor = (memberId: string): PartyWorldPosition[] => {
const peers: PartyWorldPosition[] = [];
for (const [id, position] of positionsRef.current) {
if (id !== memberId && livingIds.has(id)) peers.push(position);
}
return peers;
};
let leaderHasWaypoint = false;
let leaderMovementExpected = false;
let leaderFollowingPlayer = false;
@@ -623,7 +684,7 @@ function PartyActors({
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
followerCursorsRef.current.clear();
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? player);
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? playerNavigation);
lastLeaderWaypointRef.current = null;
}
if (leaderMember && leaderPosition && trailLeaderIdRef.current !== leaderMember.id) {
@@ -631,6 +692,7 @@ function PartyActors({
trailLeaderIdRef.current = leaderMember.id;
followerCursorsRef.current.clear();
combatRoutesRef.current.clear();
combatAnchorsRef.current.clear();
lastLeaderWaypointRef.current = null;
routeRef.current = emptyPartyRouteState();
}
@@ -640,18 +702,32 @@ function PartyActors({
let goalKey: string;
let hasDestination = true;
if (targetPosition && leaderTargetId) {
const rangeBand = partyCombatRangeBand(leader);
const actor = actorRefs.current[leaderIndex];
const movementIntent = partyCombatMovementGoal(
const profile = movementProfileFor(leader.id, leaderIndex);
const rangeBand = personalizedPartyCombatRangeBand(
partyCombatRangeBand(leader),
profile,
);
let combatAnchor = combatAnchorsRef.current.get(leader.id);
if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) {
combatAnchor = {
targetId: leaderTargetId,
bearing: Math.atan2(
leaderPosition[0] - targetPosition[0],
leaderPosition[2] - targetPosition[2],
) + profile.combatAngleOffset,
};
combatAnchorsRef.current.set(leader.id, combatAnchor);
}
const peers = peerPositionsFor(leader.id);
const crowded = peers.some((peer) => planarDistance(leaderPosition, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal(
leaderPosition,
targetPosition,
hasStaticLineOfSight(leaderPosition, targetPosition),
rangeBand.minimum,
rangeBand.preferred,
rangeBand.maximum,
rangeBand,
combatAnchor.bearing,
crowded,
leaderDestination,
actor ? Math.sin(actor.rotation.y) : 0,
actor ? Math.cos(actor.rotation.y) : 1,
);
if (movementIntent === "hold") {
hasDestination = false;
@@ -677,10 +753,9 @@ function PartyActors({
goalKey = `leader:${leader.id}|objective:${objective.id}`;
} else {
leaderFollowingPlayer = true;
leaderDestination[0] = player[0];
leaderDestination[1] = player[1];
leaderDestination[2] = player[2];
if (fullDistance(leaderPosition, player) <= PARTY_FORMATION[0][1]) hasDestination = false;
leaderDestination[0] = playerNavigation[0];
leaderDestination[1] = playerNavigation[1];
leaderDestination[2] = playerNavigation[2];
goalKey = `leader:${leader.id}|player`;
}
leaderMovementExpected = hasDestination
@@ -689,28 +764,68 @@ function PartyActors({
if (leaderFollowingPlayer) {
routeRef.current = { ...emptyPartyRouteState(), goalKey };
const cursor = leaderPlayerCursorRef.current;
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !playerTrailRef.current.points.some((point) => point.id === cursor.breadcrumbId);
leaderHasWaypoint = nextPartyBreadcrumbFollowWaypoint(
playerTrailRef.current,
cursor,
leaderPosition,
PARTY_FORMATION[0][1],
BREADCRUMB_REACHED_DISTANCE,
desired,
const profile = movementProfileFor(leader.id, leaderIndex);
const baseTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[0];
const personalizedTrailingDistance = Math.max(
0.9,
baseTrailingDistance + profile.followDistanceJitter,
);
if (
leaderHasWaypoint
&& requiresValidatedRejoin
&& !directSegmentAllowed(leaderPosition, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
leaderHasWaypoint = false;
leaderPlayerTrailBlocked = true;
partyNaturalFollowPosition(
playerNavigation,
playerForwardX,
playerForwardZ,
baseTrailingDistance,
profile,
routeNow,
leaderDestination,
);
if (directSegmentAllowed(leaderPosition, leaderDestination)) {
const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false;
leaderHasWaypoint = partyShouldMoveToComfortTarget(
leaderPosition,
leaderDestination,
wasMoving,
profile.comfortSlack,
);
directFollowMovingRef.current.set(leader.id, leaderHasWaypoint);
synchronizePartyBreadcrumbCursor(
playerTrailRef.current,
cursor,
personalizedTrailingDistance,
);
if (leaderHasWaypoint) {
desired[0] = leaderDestination[0];
desired[1] = leaderDestination[1];
desired[2] = leaderDestination[2];
}
leaderMovementExpected = leaderHasWaypoint;
} else {
directFollowMovingRef.current.set(leader.id, false);
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !playerTrailRef.current.points.some((point) => point.id === cursor.breadcrumbId);
leaderHasWaypoint = nextPartyBreadcrumbFollowWaypoint(
playerTrailRef.current,
cursor,
leaderPosition,
personalizedTrailingDistance,
BREADCRUMB_REACHED_DISTANCE,
desired,
profile.comfortSlack,
);
if (
leaderHasWaypoint
&& requiresValidatedRejoin
&& !directSegmentAllowed(leaderPosition, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
leaderHasWaypoint = false;
leaderPlayerTrailBlocked = true;
}
leaderMovementExpected = leaderHasWaypoint
|| planarDistance(leaderPosition, playerNavigation)
> personalizedTrailingDistance + profile.comfortSlack;
}
leaderMovementExpected = leaderHasWaypoint
|| planarDistance(leaderPosition, player) > PARTY_FORMATION[0][1] + 0.75;
} else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) {
if (
routeRef.current.goalKey !== goalKey
@@ -860,17 +975,32 @@ function PartyActors({
}
} else {
if (memberTargetPosition && memberTargetId) {
const rangeBand = partyCombatRangeBand(member);
const movementIntent = partyCombatMovementGoal(
const profile = movementProfileFor(member.id, index);
const rangeBand = personalizedPartyCombatRangeBand(
partyCombatRangeBand(member),
profile,
);
let combatAnchor = combatAnchorsRef.current.get(member.id);
if (!combatAnchor || combatAnchor.targetId !== memberTargetId) {
combatAnchor = {
targetId: memberTargetId,
bearing: Math.atan2(
current[0] - memberTargetPosition[0],
current[2] - memberTargetPosition[2],
) + profile.combatAngleOffset,
};
combatAnchorsRef.current.set(member.id, combatAnchor);
}
const peers = peerPositionsFor(member.id);
const crowded = peers.some((peer) => planarDistance(current, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal(
current,
memberTargetPosition,
canSeeActiveTarget,
rangeBand.minimum,
rangeBand.preferred,
rangeBand.maximum,
rangeBand,
combatAnchor.bearing,
crowded,
desired,
Math.sin(actor.rotation.y),
Math.cos(actor.rotation.y),
);
movementExpected = movementIntent !== "hold";
if (movementIntent === "hold") {
@@ -933,30 +1063,71 @@ function PartyActors({
cursor = createPartyBreadcrumbCursor();
followerCursorsRef.current.set(member.id, cursor);
}
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
const livingRank = Math.max(1, livingIndices.indexOf(index));
const trailingDistance = PARTY_TRAIL_DISTANCE[livingRank] ?? livingRank * 1.15;
hasDesired = nextPartyBreadcrumbFollowWaypoint(
leaderTrailRef.current,
cursor,
current,
trailingDistance,
BREADCRUMB_REACHED_DISTANCE,
desired,
const profile = movementProfileFor(member.id, index);
const playerTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[livingRank]
?? 1.45 + livingRank * 1.15;
const leaderTrailingDistance = Math.max(
0.9,
(PARTY_TRAIL_DISTANCE[livingRank] ?? livingRank * 1.15)
+ profile.followDistanceJitter,
);
if (
hasDesired
&& requiresValidatedRejoin
&& !directSegmentAllowed(current, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
hasDesired = false;
frameRouteBlocked = true;
partyNaturalFollowPosition(
playerNavigation,
playerForwardX,
playerForwardZ,
playerTrailingDistance,
profile,
routeNow,
naturalFollowTarget,
);
if (directSegmentAllowed(current, naturalFollowTarget)) {
const wasMoving = directFollowMovingRef.current.get(member.id) ?? false;
hasDesired = partyShouldMoveToComfortTarget(
current,
naturalFollowTarget,
wasMoving,
profile.comfortSlack,
);
directFollowMovingRef.current.set(member.id, hasDesired);
synchronizePartyBreadcrumbCursor(
leaderTrailRef.current,
cursor,
leaderTrailingDistance,
);
if (hasDesired) {
desired[0] = naturalFollowTarget[0];
desired[1] = naturalFollowTarget[1];
desired[2] = naturalFollowTarget[2];
}
movementExpected = hasDesired;
} else {
directFollowMovingRef.current.set(member.id, false);
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
hasDesired = nextPartyBreadcrumbFollowWaypoint(
leaderTrailRef.current,
cursor,
current,
leaderTrailingDistance,
BREADCRUMB_REACHED_DISTANCE,
desired,
profile.comfortSlack,
);
if (
hasDesired
&& requiresValidatedRejoin
&& !directSegmentAllowed(current, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
hasDesired = false;
frameRouteBlocked = true;
}
movementExpected = hasDesired
|| planarDistance(current, leaderPosition ?? playerNavigation)
> leaderTrailingDistance + profile.comfortSlack;
}
movementExpected = hasDesired
|| planarDistance(current, leaderPosition ?? player) > trailingDistance + 0.75;
}
if (memberTargetPosition) {
faceX = memberTargetPosition[0] - current[0];
@@ -967,9 +1138,29 @@ function PartyActors({
}
}
const currentPlanarDistance = planarDistance(current, player);
const movementProfile = movementProfileFor(member.id, index);
if (hasDesired) {
const peers = peerPositionsFor(member.id);
if (
partySeparationGoal(
current,
desired,
peers,
movementProfile.swayPhase,
separatedDesired,
)
&& directSegmentAllowed(current, separatedDesired)
) {
desired[0] = separatedDesired[0];
desired[1] = separatedDesired[1];
desired[2] = separatedDesired[2];
}
}
const currentPlanarDistance = planarDistance(current, playerNavigation);
if (member.health > 0 && party.command !== "stop" && hasDesired) {
const speed = currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED;
const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
* movementProfile.speedMultiplier;
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
// Route progress, not radial player distance, is authoritative. A
// valid path may need to move away briefly around a U-bend.
@@ -1015,7 +1206,7 @@ function PartyActors({
stuckProgress.lastProgressAt = routeNow;
} else if (partyShouldRecallForStuck(
current,
player,
playerNavigation,
movementExpected,
routeNow,
stuckProgress.lastProgressAt,
@@ -1042,7 +1233,7 @@ function PartyActors({
if (autoRecallRequested) {
party.recallParty();
lastRecallRevisionRef.current = usePartyStore.getState().recallRevision;
teleportPartyToPlayer(player, routeNow);
teleportPartyToPlayer(playerNavigation, routeNow);
return;
}
party.setRouteBlocked(frameRouteBlocked);
+90 -9
View File
@@ -13,7 +13,12 @@ import { useShellStore } from "../app/shellStore";
import type { CharacterProfile } from "../app/types";
import { CharacterModel } from "../avatar/CharacterModel";
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
import { consumeMouseLook, readInputSnapshot } from "../game/inputManager";
import {
clearJumpRequest,
consumeJumpRequest,
consumeMouseLook,
readInputSnapshot,
} from "../game/inputManager";
import { useCombatStore } from "../game/combatStore";
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
import {
@@ -33,6 +38,18 @@ import {
isBelowDungeonRecoveryPlane,
isBelowManastormRecoveryPlane,
} from "../game/playerRecovery";
import {
PLAYER_GROUND_PROBE_DISTANCE,
PLAYER_JUMP_VELOCITY,
PLAYER_LANDING_PRESENTATION_MS,
bufferPlayerJump,
cancelBufferedPlayerJump,
characterVerticalMotion,
createPlayerJumpTiming,
isWalkableGroundHit,
updatePlayerJumpTiming,
type CharacterVerticalMotion,
} from "../game/playerJump";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store";
@@ -62,6 +79,7 @@ interface PlayerControllerProps extends PlayerRigRefs {
identity: CharacterProfile | null;
equipmentItems: readonly InventoryItem[];
movingRef: React.MutableRefObject<boolean>;
verticalMotionRef: React.MutableRefObject<CharacterVerticalMotion>;
animationEventRef: React.MutableRefObject<CharacterCombatAnimationEvent | null>;
}
@@ -85,6 +103,7 @@ function PlayerController({
identity,
equipmentItems,
movingRef,
verticalMotionRef,
animationEventRef,
}: PlayerControllerProps) {
const paused = useGameStore((state) => state.paused);
@@ -98,11 +117,24 @@ function PlayerController({
const spawn = useGameStore((state) => state.activeSpawn);
const dungeon = requireDungeonDefinition(activeDungeonId);
const lastReported = useRef<[number, number, number]>([...spawn.footPosition]);
const { rapier, world } = useRapier();
const groundRay = useMemo(
() => new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }),
[rapier],
);
const jumpTimingRef = useRef(createPlayerJumpTiming());
const wasGroundedRef = useRef(true);
const landingUntilRef = useRef(Number.NEGATIVE_INFINITY);
useEffect(() => {
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
lastReported.current = [...spawn.footPosition];
}, [bodyRef, orbitRef, resetRevision, spawn]);
clearJumpRequest();
jumpTimingRef.current = createPlayerJumpTiming(performance.now());
wasGroundedRef.current = true;
landingUntilRef.current = Number.NEGATIVE_INFINITY;
verticalMotionRef.current = "grounded";
}, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]);
useFrame((_, delta) => {
const body = bodyRef.current;
@@ -131,18 +163,68 @@ function PlayerController({
}
const velocity = body.linvel();
if (paused || mapOpen || companionOpen || controlledUntil > Date.now()) {
const translation = body.translation();
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
placeAtActiveSpawn(body, orbit);
return;
}
const nowMs = performance.now();
groundRay.origin.x = translation.x;
groundRay.origin.y = translation.y;
groundRay.origin.z = translation.z;
const groundProbeLength = PLAYER_CENTER_HEIGHT + PLAYER_GROUND_PROBE_DISTANCE;
const groundHit = world.castRayAndGetNormal(
groundRay,
groundProbeLength,
true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
undefined,
undefined,
body,
);
let grounded = velocity.y <= 0.5 && isWalkableGroundHit(groundHit, groundProbeLength);
const requestedAtMs = consumeJumpRequest();
const gameplayBlocked = paused || mapOpen || companionOpen || controlledUntil > Date.now();
if (gameplayBlocked) {
cancelBufferedPlayerJump(jumpTimingRef.current);
movingRef.current = false;
verticalMotionRef.current = characterVerticalMotion(
grounded,
velocity.y,
nowMs,
landingUntilRef.current,
);
body.setLinvel({ x: 0, y: velocity.y, z: 0 }, true);
return;
}
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
const shouldJump = updatePlayerJumpTiming(
jumpTimingRef.current,
nowMs,
grounded,
velocity.y,
);
const verticalVelocity = shouldJump ? PLAYER_JUMP_VELOCITY : velocity.y;
if (shouldJump) grounded = false;
if (grounded && !wasGroundedRef.current) {
landingUntilRef.current = nowMs + PLAYER_LANDING_PRESENTATION_MS;
}
wasGroundedRef.current = grounded;
verticalMotionRef.current = characterVerticalMotion(
grounded,
verticalVelocity,
nowMs,
landingUntilRef.current,
);
const [worldX, worldZ] = cameraRelativeMovement(
snapshot.moveX,
snapshot.moveForward,
orbit.yaw,
);
body.setLinvel({ x: worldX * MOVE_SPEED, y: velocity.y, z: worldZ * MOVE_SPEED }, true);
body.setLinvel({ x: worldX * MOVE_SPEED, y: verticalVelocity, z: worldZ * MOVE_SPEED }, true);
const moving = Math.hypot(worldX, worldZ) > 0.05;
movingRef.current = moving;
@@ -152,11 +234,6 @@ function PlayerController({
avatarRef.current.rotation.y += difference * (1 - Math.exp(-14 * delta));
}
const translation = body.translation();
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
placeAtActiveSpawn(body, orbit);
return;
}
const activeManastormPackage = gameMode === "manastorm"
? activeManastormStageAssetPackage()
: null;
@@ -185,6 +262,7 @@ function PlayerController({
position: footPosition,
yaw: orbit.yaw,
distanceDelta,
grounded,
});
lastReported.current = footPosition;
}
@@ -214,6 +292,7 @@ function PlayerController({
equipment={equipmentItems}
active={!paused && !mapOpen && !companionOpen}
movingRef={movingRef}
verticalMotionRef={verticalMotionRef}
animationEventRef={animationEventRef}
fallback={<ProceduralAvatar accent={classById(identity?.classId ?? "priest").color} />}
/>
@@ -331,6 +410,7 @@ export function PlayerRig() {
const bodyRef = useRef<RapierRigidBody>(null);
const avatarRef = useRef<Object3D>(null);
const movingRef = useRef(false);
const verticalMotionRef = useRef<CharacterVerticalMotion>("grounded");
const animationEventRef = useRef<CharacterCombatAnimationEvent | null>(animationEvent);
animationEventRef.current = animationEvent;
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -349,6 +429,7 @@ export function PlayerRig() {
identity={activeCharacter}
equipmentItems={equipmentItems}
movingRef={movingRef}
verticalMotionRef={verticalMotionRef}
animationEventRef={animationEventRef}
/>
<ThirdPersonCamera bodyRef={bodyRef} orbitRef={orbitRef} avatarRef={avatarRef} />
+1
View File
@@ -707,6 +707,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.aura-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; }
.aura { position: relative; display: inline-grid; width: 22px; height: 22px; place-items: center; overflow: hidden; border: 1px solid rgba(255,255,255,.35); border-radius: 3px; color: #fff; background: linear-gradient(145deg, #42669a, #18273e); box-shadow: 0 1px 3px rgba(0,0,0,.7); }
.aura--debuff { border-color: rgba(243,118,105,.75); background: linear-gradient(145deg, #8c3d45, #35191f); }
.aura__icon { width: 100%; height: 100%; object-fit: cover; }
.aura__initial { font: 700 11px/1 var(--font-ui); text-shadow: 0 1px 2px #000; }
.aura__timer { position: absolute; right: 1px; bottom: 0; font: 700 7px/8px var(--font-ui); text-shadow: 0 1px 2px #000; }
.aura__stacks { position: absolute; right: 1px; top: 0; font: 700 8px/8px var(--font-ui); color: #fff4b8; text-shadow: 0 1px 2px #000; }
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import type { AuraDefinition } from "../game/combatAuras";
import { auraDefinitionDisplaysInStrip, auraDefinitionIcon } from "./AuraStrip";
function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
return {
id: "test-aura",
name: "Test Aura",
disposition: "debuff",
durationMs: 10_000,
...overrides,
};
}
describe("aura strip visibility", () => {
it("shows ordinary auras and suppresses periodic shadow auras with dedicated timers", () => {
expect(auraDefinitionDisplaysInStrip(aura())).toBe(true);
expect(auraDefinitionDisplaysInStrip(aura({ hideFromAuraStrip: true }))).toBe(false);
});
it("uses spell artwork while retaining the initial fallback for iconless effects", () => {
expect(auraDefinitionIcon(aura({ icon: " /assets/ui/spells/test.png " }))).toBe(
"/assets/ui/spells/test.png",
);
expect(auraDefinitionIcon(aura())).toBeNull();
});
});
+14 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import type { AuraDisposition } from "../game/combatAuras";
import type { AuraDefinition, AuraDisposition } from "../game/combatAuras";
import { useCombatStore } from "../game/combatStore";
import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
@@ -9,6 +9,14 @@ interface AuraStripProps {
readonly className?: string;
}
export function auraDefinitionDisplaysInStrip(definition: AuraDefinition): boolean {
return definition.hideFromAuraStrip !== true;
}
export function auraDefinitionIcon(definition: AuraDefinition): string | null {
return definition.icon?.trim() || null;
}
export function AuraStrip({
targetId,
dispositions = ["buff", "debuff"],
@@ -19,6 +27,7 @@ export function AuraStrip({
const auras = allAuras.filter((aura) => (
aura.targetId === targetId
&& dispositions.includes(aura.definition.disposition)
&& auraDefinitionDisplaysInStrip(aura.definition)
&& (aura.expiresAt === null || aura.expiresAt > now)
));
@@ -36,6 +45,7 @@ export function AuraStrip({
const remaining = aura.expiresAt === null ? null : Math.max(0, aura.expiresAt - now);
const timer = remaining === null ? "" : formatTimedEffectTimer(remaining);
const initial = aura.definition.name.trim().charAt(0).toUpperCase() || "*";
const icon = auraDefinitionIcon(aura.definition);
return (
<span
key={aura.instanceId}
@@ -43,7 +53,9 @@ export function AuraStrip({
aria-label={`${aura.definition.name}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
title={`${aura.definition.name}${timer ? ` · ${timer} remaining` : ""}`}
>
<span className="aura__initial" aria-hidden="true">{initial}</span>
{icon
? <img className="aura__icon" src={icon} alt="" draggable={false} />
: <span className="aura__initial" aria-hidden="true">{initial}</span>}
{aura.stacks > 1 ? <b className="aura__stacks">{aura.stacks}</b> : null}
{timer ? <b className="aura__timer" aria-hidden="true">{timer}</b> : null}
</span>
+6 -3
View File
@@ -34,12 +34,15 @@ export function CharacterCreateScreen() {
const race = raceById(draft.raceId);
const characterClass = classById(draft.classId);
const availableRaces = useMemo(() => racesForCategory(draft.categoryId), [draft.categoryId]);
const availableClasses = useMemo(() => classesForRace(draft.raceId), [draft.raceId]);
const availableClasses = useMemo(
() => classesForRace(draft.raceId, draft.categoryId),
[draft.categoryId, draft.raceId],
);
const appearanceLabels = race.labels[draft.gender];
const appearanceCounts = race.counts[draft.gender];
const chooseRace = (raceId: RaceId) => {
const nextClasses = classesForRace(raceId);
const nextClasses = classesForRace(raceId, draft.categoryId);
const nextGenders = supportedGenders(raceId);
const classId = nextClasses.some((definition) => definition.id === draft.classId)
? draft.classId
@@ -154,7 +157,7 @@ export function CharacterCreateScreen() {
</div>
</main>
<section className="creator-classes">
<header><span>02</span><div><strong>Class</strong><small>{availableClasses.length} {draft.categoryId === "rom" ? "RuneWaker" : "WoW / Ascension"} classes available to {race.name}</small></div></header>
<header><span>02</span><div><strong>Class</strong><small>{availableClasses.length} {draft.categoryId === "rom" ? "RuneWaker" : draft.categoryId === "coa" ? "CoA" : "WoW"} classes available to {race.name}</small></div></header>
<div className="class-grid">
{availableClasses.map((definition) => (
<ControllerButton
+5 -3
View File
@@ -1,5 +1,6 @@
import { useMemo, useState, type CSSProperties } from "react";
import { CLASSES, PLAYABLE_RACES, classById, FACTIONS, raceById } from "../app/characterCatalog";
import { CLASSES, PLAYABLE_RACES, classById, contentCategoryForClass, FACTIONS, raceById } from "../app/characterCatalog";
import { contentCategoryById } from "../app/contentCategories";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useMenuController, type MenuAction } from "../input/useMenuController";
@@ -61,6 +62,7 @@ export function CharacterSelectScreen() {
{characters.map((character) => {
const raceDefinition = raceById(character.raceId);
const classDefinition = classById(character.classId);
const categoryDefinition = contentCategoryById(character.categoryId ?? contentCategoryForClass(character.classId));
return (
<ControllerButton
key={character.id}
@@ -72,7 +74,7 @@ export function CharacterSelectScreen() {
onClick={() => select(character.id)}
>
<span style={{ "--portrait-accent": raceDefinition.accent } as CSSProperties}>{raceDefinition.sigil}</span>
<i><strong>{character.name}</strong><small>{character.categoryId === "rom" ? "RoM" : "WoW"} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""}</small></i>
<i><strong>{character.name}</strong><small>{categoryDefinition.label} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""}</small></i>
<em>{formatLastPlayed(character.lastPlayedAt)}</em>
</ControllerButton>
);
@@ -110,7 +112,7 @@ export function CharacterSelectScreen() {
<h2>{selected.location}</h2>
<dl>
<div><dt>Level</dt><dd>{selected.level}</dd></div>
<div><dt>Content</dt><dd>{selected.categoryId === "rom" ? "Runes of Magic" : "World of Warcraft"}</dd></div>
<div><dt>Content</dt><dd>{contentCategoryById(selected.categoryId ?? contentCategoryForClass(selected.classId)).name}</dd></div>
<div><dt>Faction</dt><dd>{FACTIONS[race.faction].name}</dd></div>
<div><dt>Class role</dt><dd>{characterClass.role}</dd></div>
<div><dt>Next step</dt><dd>Choose a game mode</dd></div>
+2 -1
View File
@@ -78,10 +78,11 @@ function PauseDialog() {
</div>
<dl className="controls-list">
<div><dt>Move</dt><dd>WASD / Left stick</dd></div>
<div><dt>Jump</dt><dd>Space / L3</dd></div>
<div><dt>Look</dt><dd>Hold right mouse / Arrow keys / Right stick</dd></div>
<div><dt>Abilities</dt><dd>18 / Face, R1, R2, D-pad left/right</dd></div>
<div><dt>Ability layers</dt><dd>Shift / Alt / Hold L1 / Hold L2</dd></div>
<div><dt>Targets</dt><dd>Tab / R3 enemy · D-pad up/down party/self · L3 clear</dd></div>
<div><dt>Targets</dt><dd>Tab / R3 enemy · D-pad up/down party/self</dd></div>
<div><dt>Party orders</dt><dd>F1 Attack / F2 Defend / F3 Stop / F4 Recall</dd></div>
<div><dt>Spellbook / {skillMenuLabel}</dt><dd>P / N</dd></div>
<div><dt>Inventory / Loot</dt><dd>I / E</dd></div>