From c00f3211ea390d273061dba112437243354159c0 Mon Sep 17 00:00:00 2001 From: phenom Date: Sun, 16 Aug 2026 11:16:51 -0400 Subject: [PATCH] updating buff and debuff icons. separated COA classes from WoW classes on the character creation screen --- DEPLOYMENT.md | 106 +++- PLAYTEST_NOTES.md | 87 +++ README.md | 3 +- RELEASE_RUNBOOK.md | 606 +++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/app/accountRepository.test.ts | 17 + src/app/accountRepository.ts | 7 +- src/app/characterCatalog.test.ts | 39 +- src/app/characterCatalog.ts | 66 ++- src/app/contentCategories.ts | 8 +- src/app/shellStore.test.ts | 12 +- src/app/shellStore.ts | 12 +- src/avatar/CharacterModel.tsx | 47 +- src/game/combatAnimation.test.ts | 27 + src/game/combatAnimation.ts | 30 + src/game/combatAuraRuntime.test.ts | 13 +- src/game/combatAuras.ts | 4 + src/game/combatStore.ts | 27 +- src/game/dungeonCatalog.ts | 14 +- src/game/inputManager.test.ts | 31 +- src/game/inputManager.ts | 22 +- src/game/partyMovement.test.ts | 98 ++++ src/game/partyMovement.ts | 222 +++++++- src/game/playerJump.test.ts | 66 +++ src/game/playerJump.ts | 94 ++++ src/game/runewakerDungeonAnimation.test.ts | 4 +- src/game/store.test.ts | 31 ++ src/game/store.ts | 15 +- src/game/wow335AbilityCatalog.test.ts | 20 + src/game/wow335AbilityCatalog.ts | 3 + src/scene/PartyPopulation.tsx | 345 +++++++++--- src/scene/PlayerRig.tsx | 99 +++- src/styles.css | 1 + src/ui/AuraStrip.test.ts | 27 + src/ui/AuraStrip.tsx | 16 +- src/ui/CharacterCreateScreen.tsx | 9 +- src/ui/CharacterSelectScreen.tsx | 8 +- src/ui/PauseMenu.tsx | 3 +- 39 files changed, 2037 insertions(+), 208 deletions(-) create mode 100644 RELEASE_RUNBOOK.md create mode 100644 src/game/playerJump.test.ts create mode 100644 src/game/playerJump.ts create mode 100644 src/ui/AuraStrip.test.ts diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index a8326b10..a8e226b9 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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 = '...' diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 265f24bd..aa732dba 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -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. diff --git a/README.md b/README.md index c4e3ddf1..eb4448c2 100644 --- a/README.md +++ b/README.md @@ -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 1–8; 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 diff --git a/RELEASE_RUNBOOK.md b/RELEASE_RUNBOOK.md new file mode 100644 index 00000000..114319af --- /dev/null +++ b/RELEASE_RUNBOOK.md @@ -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 -- ` 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 +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`. diff --git a/package-lock.json b/package-lock.json index f039feec..babb56ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ca972967..c4523af4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/accountRepository.test.ts b/src/app/accountRepository.test.ts index 98ff5182..ce144b22 100644 --- a/src/app/accountRepository.test.ts +++ b/src/app/accountRepository.test.ts @@ -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(); diff --git a/src/app/accountRepository.ts b/src/app/accountRepository.ts index aa53ccab..ed9c530b 100644 --- a/src/app/accountRepository.ts +++ b/src/app/accountRepository.ts @@ -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" diff --git a/src/app/characterCatalog.test.ts b/src/app/characterCatalog.test.ts index 1f2d5f37..30556c24 100644 --- a/src/app/characterCatalog.test.ts +++ b/src/app/characterCatalog.test.ts @@ -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"]); }); diff --git a/src/app/characterCatalog.ts b/src/app/characterCatalog.ts index 51e002d3..d1924324 100644 --- a/src/app/characterCatalog.ts +++ b/src/app/characterCatalog.ts @@ -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) )); } diff --git a/src/app/contentCategories.ts b/src/app/contentCategories.ts index c813b023..8ca870ac 100644 --- a/src/app/contentCategories.ts +++ b/src/app/contentCategories.ts @@ -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]; +} diff --git a/src/app/shellStore.test.ts b/src/app/shellStore.test.ts index 32b295e4..83bcb25f 100644 --- a/src/app/shellStore.test.ts +++ b/src/app/shellStore.test.ts @@ -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", diff --git a/src/app/shellStore.ts b/src/app/shellStore.ts index 39fbb3e7..589b8c4c 100644 --- a/src/app/shellStore.ts +++ b/src/app/shellStore.ts @@ -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((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((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; } diff --git a/src/avatar/CharacterModel.tsx b/src/avatar/CharacterModel.tsx index 85140df1..dfb17bc6 100644 --- a/src/avatar/CharacterModel.tsx +++ b/src/avatar/CharacterModel.tsx @@ -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; + verticalMotionRef?: MutableRefObject; attackPulseRef?: MutableRefObject; animationEventRef?: MutableRefObject; onReady?: () => void; @@ -88,6 +91,8 @@ export interface CharacterModelProps { const EMPTY_EQUIPMENT: readonly InventoryItem[] = Object.freeze([]); +type CharacterLocomotionMotion = CharacterBaseMotion | Exclude; + 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; + verticalMotionRef?: MutableRefObject; attackPulseRef?: MutableRefObject; animationEventRef?: MutableRefObject; onReady?: () => void; @@ -190,7 +197,7 @@ function LoadedCharacterModel({ const overrideAction = useRef(null); const overrideTerminal = useRef(false); const overrideLooping = useRef(false); - const currentMotion = useRef(null); + const currentMotion = useRef(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} diff --git a/src/game/combatAnimation.test.ts b/src/game/combatAnimation.test.ts index 71ac024e..8c41bf0b 100644 --- a/src/game/combatAnimation.test.ts +++ b/src/game/combatAnimation.test.ts @@ -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]); }); }); diff --git a/src/game/combatAnimation.ts b/src/game/combatAnimation.ts index 28302f78..a09d6a78 100644 --- a/src/game/combatAnimation.ts +++ b/src/game/combatAnimation.ts @@ -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( return selectWoWAnimationVariation(clips, ids); } +export function selectCharacterVerticalClip( + clips: readonly TClip[], + motion: Exclude, + moving: boolean, +): CharacterClipSelection | 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; diff --git a/src/game/combatAuraRuntime.test.ts b/src/game/combatAuraRuntime.test.ts index 1eb1f75b..92eb0eaf 100644 --- a/src/game/combatAuraRuntime.test.ts +++ b/src/game/combatAuraRuntime.test.ts @@ -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 diff --git a/src/game/combatAuras.ts b/src/game/combatAuras.ts index dc05b732..47bd61a4 100644 --- a/src/game/combatAuras.ts +++ b/src/game/combatAuras.ts @@ -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; diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index f6a410da..26cb063c 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -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 => 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((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" diff --git a/src/game/dungeonCatalog.ts b/src/game/dungeonCatalog.ts index c77b004c..79a2fdfd 100644 --- a/src/game/dungeonCatalog.ts +++ b/src/game/dungeonCatalog.ts @@ -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 { diff --git a/src/game/inputManager.test.ts b/src/game/inputManager.test.ts index c29c8562..5f3e326f 100644 --- a/src/game/inputManager.test.ts +++ b/src/game/inputManager.test.ts @@ -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; + 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, diff --git a/src/game/inputManager.ts b/src/game/inputManager.ts index e1fb7ff1..60710437 100644 --- a/src/game/inputManager.ts +++ b/src/game/inputManager.ts @@ -42,6 +42,7 @@ export interface InputActions { const keys = new Set(); 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"); diff --git a/src/game/partyMovement.test.ts b/src/game/partyMovement.test.ts index 028692ac..79e30ba3 100644 --- a/src/game/partyMovement.test.ts +++ b/src/game/partyMovement.test.ts @@ -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(); diff --git a/src/game/partyMovement.ts b/src/game/partyMovement.ts index b295f8ba..9812af9a 100644 --- a/src/game/partyMovement.ts +++ b/src/game/partyMovement.ts @@ -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; +} diff --git a/src/game/playerJump.test.ts b/src/game/playerJump.test.ts new file mode 100644 index 00000000..be3bc8a7 --- /dev/null +++ b/src/game/playerJump.test.ts @@ -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"); + }); +}); diff --git a/src/game/playerJump.ts b/src/game/playerJump.ts new file mode 100644 index 00000000..5b18647a --- /dev/null +++ b/src/game/playerJump.ts @@ -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"; +} diff --git a/src/game/runewakerDungeonAnimation.test.ts b/src/game/runewakerDungeonAnimation.test.ts index 1ca0e605..1cdfde25 100644 --- a/src/game/runewakerDungeonAnimation.test.ts +++ b/src/game/runewakerDungeonAnimation.test.ts @@ -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"); diff --git a/src/game/store.test.ts b/src/game/store.test.ts index 96bd54db..6bc1f83d 100644 --- a/src/game/store.test.ts +++ b/src/game/store.test.ts @@ -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"); diff --git a/src/game/store.ts b/src/game/store.ts index 97f1fb05..3e1985e6 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -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((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((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((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((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((set) => ({ mapOpen: false, cameraLookActive: false, playerPosition: activeEntrance.footPosition, + playerNavigationPosition: activeEntrance.footPosition, + playerGrounded: true, cameraYaw: activeEntrance.yaw, areaName: resolveAreaNameForDungeon(definition, activeEntrance.footPosition), distanceTravelled: 0, diff --git a/src/game/wow335AbilityCatalog.test.ts b/src/game/wow335AbilityCatalog.test.ts index 0d6f9339..20b08305 100644 --- a/src/game/wow335AbilityCatalog.test.ts +++ b/src/game/wow335AbilityCatalog.test.ts @@ -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"] }), diff --git a/src/game/wow335AbilityCatalog.ts b/src/game/wow335AbilityCatalog.ts index e9bea6a0..ecefea2d 100644 --- a/src/game/wow335AbilityCatalog.ts +++ b/src/game/wow335AbilityCatalog.ts @@ -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", diff --git a/src/scene/PartyPopulation.tsx b/src/scene/PartyPopulation.tsx index ac3d386f..4953692a 100644 --- a/src/scene/PartyPopulation.tsx +++ b/src/scene/PartyPopulation.tsx @@ -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()); + const movementProfilesRef = useRef(new Map()); + const directFollowMovingRef = useRef(new Map()); + const combatAnchorsRef = useRef(new Map()); 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); diff --git a/src/scene/PlayerRig.tsx b/src/scene/PlayerRig.tsx index 7f077d8b..ee1928e2 100644 --- a/src/scene/PlayerRig.tsx +++ b/src/scene/PlayerRig.tsx @@ -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; + verticalMotionRef: React.MutableRefObject; animationEventRef: React.MutableRefObject; } @@ -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={} /> @@ -331,6 +410,7 @@ export function PlayerRig() { const bodyRef = useRef(null); const avatarRef = useRef(null); const movingRef = useRef(false); + const verticalMotionRef = useRef("grounded"); const animationEventRef = useRef(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} /> diff --git a/src/styles.css b/src/styles.css index 63e0947a..8d71a8d7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/src/ui/AuraStrip.test.ts b/src/ui/AuraStrip.test.ts new file mode 100644 index 00000000..5aa77dfb --- /dev/null +++ b/src/ui/AuraStrip.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/ui/AuraStrip.tsx b/src/ui/AuraStrip.tsx index 00fff0fb..9c31281e 100644 --- a/src/ui/AuraStrip.tsx +++ b/src/ui/AuraStrip.tsx @@ -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 ( - + {icon + ? + : } {aura.stacks > 1 ? {aura.stacks} : null} {timer ? : null} diff --git a/src/ui/CharacterCreateScreen.tsx b/src/ui/CharacterCreateScreen.tsx index 3a79819e..4af61bbb 100644 --- a/src/ui/CharacterCreateScreen.tsx +++ b/src/ui/CharacterCreateScreen.tsx @@ -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() {
-
02
Class{availableClasses.length} {draft.categoryId === "rom" ? "RuneWaker" : "WoW / Ascension"} classes available to {race.name}
+
02
Class{availableClasses.length} {draft.categoryId === "rom" ? "RuneWaker" : draft.categoryId === "coa" ? "CoA" : "WoW"} classes available to {race.name}
{availableClasses.map((definition) => ( { const raceDefinition = raceById(character.raceId); const classDefinition = classById(character.classId); + const categoryDefinition = contentCategoryById(character.categoryId ?? contentCategoryForClass(character.classId)); return ( select(character.id)} > {raceDefinition.sigil} - {character.name}{character.categoryId === "rom" ? "RoM" : "WoW"} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""} + {character.name}{categoryDefinition.label} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""} {formatLastPlayed(character.lastPlayedAt)} ); @@ -110,7 +112,7 @@ export function CharacterSelectScreen() {

{selected.location}

Level
{selected.level}
-
Content
{selected.categoryId === "rom" ? "Runes of Magic" : "World of Warcraft"}
+
Content
{contentCategoryById(selected.categoryId ?? contentCategoryForClass(selected.classId)).name}
Faction
{FACTIONS[race.faction].name}
Class role
{characterClass.role}
Next step
Choose a game mode
diff --git a/src/ui/PauseMenu.tsx b/src/ui/PauseMenu.tsx index aca1e08b..d62c0e38 100644 --- a/src/ui/PauseMenu.tsx +++ b/src/ui/PauseMenu.tsx @@ -78,10 +78,11 @@ function PauseDialog() {
Move
WASD / Left stick
+
Jump
Space / L3
Look
Hold right mouse / Arrow keys / Right stick
Abilities
1–8 / Face, R1, R2, D-pad left/right
Ability layers
Shift / Alt / Hold L1 / Hold L2
-
Targets
Tab / R3 enemy · D-pad up/down party/self · L3 clear
+
Targets
Tab / R3 enemy · D-pad up/down party/self
Party orders
F1 Attack / F2 Defend / F3 Stop / F4 Recall
Spellbook / {skillMenuLabel}
P / N
Inventory / Loot
I / E